Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,847 changes: 1,500 additions & 1,347 deletions OPENAPI_DOC.yml

Large diffs are not rendered by default.

21 changes: 18 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ services:
- redis
- postgres
- migrator
- browser
security_opt:
- seccomp:unconfined
environment:
Expand All @@ -54,6 +55,8 @@ services:
*redis-client-env,
*postgresdb-client-env,
]
BROWSER_URI: ${BROWSER_URI:-http://browser:3000}
BROWSER_TOKEN: ${BROWSER_TOKEN:-browser-token}

redis:
image: eqalpha/keydb
Expand All @@ -64,6 +67,16 @@ services:
ports:
- 6379:6379

browser: # headless chrome for URL screenshots
image: ghcr.io/browserless/chromium:latest
restart: always
hostname: browser
environment:
TOKEN: ${BROWSER_TOKEN:-browser-token}
CONCURRENT: ${BROWSER_CONCURRENT:-5}
QUEUED: ${BROWSER_QUEUED:-10}
TIMEOUT: ${BROWSER_TIMEOUT:-60000}

postgres:
hostname: postgres
image: postgres
Expand Down Expand Up @@ -128,7 +141,9 @@ services:
<<: *s3-client-env

minio:
image: quay.io/minio/minio:latest
# upstream stopped publishing images (quay.io/docker.io minio/* now 401);
# pgsty maintains drop-in builds (amd64 + arm64)
image: pgsty/minio:latest
volumes:
- s3:/data
ports:
Expand All @@ -141,15 +156,15 @@ services:
command: server /data --console-address ":9090"

testbucket:
image: quay.io/minio/mc:latest
image: pgsty/mc:latest
depends_on:
- minio
environment:
<< : *s3-client-env
entrypoint: >
sh -c '
sleep 3 &&
mc config host add s3 $AWS_S3_ENDPOINT $AWS_KEY $AWS_SECRET &&
mc alias set s3 $AWS_S3_ENDPOINT $AWS_KEY $AWS_SECRET &&
mc mb -p s3/$AWS_S3_BUCKET &&
exit 0
'
Expand Down
239 changes: 239 additions & 0 deletions spec/controllers/uploads_screenshot_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
require "../helper"

module PlaceOS::Api
# A real 1x1 PNG, standing in for what the browser renders.
SCREENSHOT_TINY_PNG = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")

describe "Uploads#screenshot" do
path = File.join(Uploads.base_route, "screenshot")
browser = /browser:3000\/chromium\/screenshot/
object_store = /amazonaws\.com|blob\.core\.windows\.net/

# the domain's default storage; signed URLs point at amazonaws
setup_storage = -> {
authority = Model::Authority.find_by_domain("localhost").not_nil!
Model::Generator.storage(authority_id: authority.id.as(String)).save!
}

# `Screenshot.capture` streams the reply (`client.post(...) { |response| }`),
# and WebMock hands a stubbed response to that block as-is, so it has to
# carry a `body_io` rather than a `body` string
browser_response = ->(status : Int32, bytes : Bytes) {
HTTP::Client::Response.new(status, headers: HTTP::Headers{"Content-Type" => "image/png"}, body_io: IO::Memory.new(bytes))
}

# browser stub answering with `bytes`, recording each request it sees
stub_browser = ->(requests : Array(HTTP::Request), bodies : Array(String), bytes : Bytes) {
WebMock.stub(:post, browser).to_return do |request|
requests << request
bodies << WebMock.body(request).to_s
browser_response.call(200, bytes)
end
}

before_each do
Model::Upload.clear
Model::Storage.clear
# an unstubbed browser or bucket call should fail loudly
WebMock.allow_net_connect = false
end

it "renders the page, stores the image and returns the upload" do
storage = setup_storage.call
user, headers = Spec::Authentication.authentication
requests = [] of HTTP::Request
bodies = [] of String
stub_browser.call(requests, bodies, SCREENSHOT_TINY_PNG)
WebMock.stub(:put, object_store).to_return(body: "", status: 200)

result = client.post(path, headers: headers, body: {
url: "https://www.example.com/dashboard?x=1",
tags: ["lobby", "signage"],
}.to_json)

result.status_code.should eq 201
body = JSON.parse(result.body)
upload = Model::Upload.find!(body["id"].as_s)
upload.upload_complete.should be_true
upload.storage_id.should eq storage.id
upload.uploaded_by.should eq user.id
upload.uploaded_email.should eq user.email
upload.file_size.should eq SCREENSHOT_TINY_PNG.size
upload.file_md5.should eq Digest::MD5.base64digest(SCREENSHOT_TINY_PNG)
upload.tags.should contain "screenshot"
upload.tags.should contain "lobby"
upload.tags.should contain "signage"
upload.tags.size.should eq 3
upload.public.should be_false
upload.object_options["headers"]["Content-Type"].as_s.should eq "image/png"
upload.object_options["permissions"].as_s.should eq "private"
upload.file_name.should eq "screenshot-www.example.com-1920x1080.png"
upload.object_key.should start_with "/localhost/"
upload.object_key.should end_with ".png"
body["upload_complete"].as_bool.should be_true

requests.size.should eq 1
requests.first.headers["Authorization"].should eq "Bearer browser-token"
requests.first.query_params["token"]?.should be_nil
requests.first.query_params["timeout"].should eq SCREENSHOT_TIMEOUT.total_milliseconds.to_i.to_s
JSON.parse(bodies.first)["url"].as_s.should eq "https://www.example.com/dashboard?x=1"
end

it "sends the viewport, format and readiness options to the browser" do
setup_storage.call
requests = [] of HTTP::Request
bodies = [] of String
stub_browser.call(requests, bodies, SCREENSHOT_TINY_PNG)
WebMock.stub(:put, object_store).to_return(body: "", status: 200)

result = client.post(path, headers: Spec::Authentication.headers, body: {
url: "https://example.com",
width: 1280,
height: 720,
scale: 2.0,
full_page: true,
settle: 2500,
}.to_json)

result.status_code.should eq 201
sent = JSON.parse(bodies.first)
sent["viewport"]["width"].as_i.should eq 1280
sent["viewport"]["height"].as_i.should eq 720
sent["viewport"]["deviceScaleFactor"].as_f.should eq 2.0
sent["options"]["type"].as_s.should eq "png"
sent["options"]["fullPage"].as_bool.should be_true
sent["gotoOptions"]["waitUntil"].as_s.should eq "networkidle2"
sent["rejectRequestPattern"].as_a.map(&.as_s).should eq ["^http:"]
function = sent["waitForFunction"]["fn"].as_s
function.should contain "document.fonts.ready"
function.should contain "2500"
sent["waitForFunction"]["timeout"].as_i.should eq 2500 + Screenshot::READY_TIMEOUT_MS

JSON.parse(result.body)["file_name"].as_s.should eq "screenshot-example.com-1280x720.png"
end

it "stores a jpeg and sanitizes a custom file name" do
setup_storage.call
requests = [] of HTTP::Request
bodies = [] of String
stub_browser.call(requests, bodies, Base64.decode(HttpMocks::TINY_JPEG))
WebMock.stub(:put, object_store).to_return(body: "", status: 200)

result = client.post(path, headers: Spec::Authentication.headers, body: {
url: "https://example.com",
format: "jpeg",
}.to_json)

result.status_code.should eq 201
JSON.parse(bodies.first)["options"]["type"].as_s.should eq "jpeg"
upload = Model::Upload.find!(JSON.parse(result.body)["id"].as_s)
upload.object_options["headers"]["Content-Type"].as_s.should eq "image/jpeg"
upload.file_name.should eq "screenshot-example.com-1920x1080.jpg"
upload.object_key.should end_with ".jpg"

result = client.post(path, headers: Spec::Authentication.headers, body: {
url: "https://example.com",
format: "jpeg",
file_name: "../reports/my weekly (v2).jpg",
}.to_json)

result.status_code.should eq 201
Model::Upload.find!(JSON.parse(result.body)["id"].as_s).file_name.should eq "my_weekly__v2_.jpg"
end

it "rejects invalid requests with 400 without calling the browser" do
setup_storage.call
requests = [] of HTTP::Request
bodies = [] of String
stub_browser.call(requests, bodies, SCREENSHOT_TINY_PNG)
WebMock.stub(:put, object_store).to_return(body: "", status: 200)
headers = Spec::Authentication.headers

[
{url: "http://example.com"},
{url: "/relative/page"},
{url: "https:///no-host"},
{url: "https://example.com", width: 0},
{url: "https://example.com", width: 99999},
{url: "https://example.com", height: 0},
{url: "https://example.com", scale: 10.0},
{url: "https://example.com", settle: 20000},
{url: "https://example.com", format: "gif"},
{url: "https://example.com", tags: ["has space"]},
{url: "https://example.com", tags: ["<script>"]},
].each do |payload|
result = client.post(path, headers: headers, body: payload.to_json)
result.status_code.should eq(400), "expected 400 for #{payload.to_json}, got #{result.status_code}: #{result.body}"
end

requests.should be_empty
Model::Upload.count.should eq 0
end

it "maps a browser error to 502 and stores nothing" do
setup_storage.call
WebMock.stub(:post, browser).to_return { |_request| browser_response.call(500, "boom".to_slice) }
WebMock.stub(:put, object_store).to_return(body: "", status: 200)

result = client.post(path, headers: Spec::Authentication.headers, body: {url: "https://example.com"}.to_json)

result.status_code.should eq 502
Model::Upload.count.should eq 0
end

it "maps a browser timeout to 504 and stores nothing" do
setup_storage.call
WebMock.stub(:post, browser).to_return { |_request| browser_response.call(408, "timed out".to_slice) }
WebMock.stub(:put, object_store).to_return(body: "", status: 200)

result = client.post(path, headers: Spec::Authentication.headers, body: {url: "https://example.com"}.to_json)

result.status_code.should eq 504
Model::Upload.count.should eq 0
end

it "maps a storage rejection to 502 and removes the upload row" do
setup_storage.call
requests = [] of HTTP::Request
bodies = [] of String
stub_browser.call(requests, bodies, SCREENSHOT_TINY_PNG)
WebMock.stub(:put, object_store).to_return(body: "AccessDenied", status: 403)

result = client.post(path, headers: Spec::Authentication.headers, body: {url: "https://example.com"}.to_json)

result.status_code.should eq 502
requests.size.should eq 1
Model::Upload.count.should eq 0
end

it "maps an unreachable storage to 502 and removes the upload row" do
setup_storage.call
requests = [] of HTTP::Request
bodies = [] of String
stub_browser.call(requests, bodies, SCREENSHOT_TINY_PNG)
WebMock.stub(:put, object_store).to_return do |_request|
raise IO::Error.new("connection reset by storage")
end

result = client.post(path, headers: Spec::Authentication.headers, body: {url: "https://example.com"}.to_json)

result.status_code.should eq 502
requests.size.should eq 1
Model::Upload.count.should eq 0
end

it "requires authentication" do
setup_storage.call
requests = [] of HTTP::Request
bodies = [] of String
stub_browser.call(requests, bodies, SCREENSHOT_TINY_PNG)

result = client.post(path,
headers: HTTP::Headers{"Host" => "localhost", "Content-Type" => "application/json"},
body: {url: "https://example.com"}.to_json)

result.status_code.should eq 401
requests.should be_empty
end
end
end
6 changes: 6 additions & 0 deletions src/constants.cr
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ module PlaceOS::Api
OPENAI_API_MODEL = ENV["OPENAI_API_MODEL"]? || "gpt-5-mini"
OPENAI_MAX_TOKENS = ENV["OPENAI_MAX_TOKENS"]?.try(&.to_i) || 400_000

# Headless browser (browserless) used by `POST /uploads/screenshot`
BROWSER_URI = URI.parse(ENV["BROWSER_URI"]? || "http://browser:3000")
# defaults to the shared service secret, as dispatch uses
BROWSER_TOKEN = ENV["BROWSER_TOKEN"]?.presence || ENV["PLACE_SERVER_SECRET"]?.presence || ENV["SERVER_SECRET"]?.presence
SCREENSHOT_TIMEOUT = (ENV["SCREENSHOT_TIMEOUT"]? || "45").to_i.seconds

# Upload temporary links
TEMP_LINK_MAX_MINUTES = ENV["TEMP_LINK_MAX_MINUTES"]?.try(&.to_i) || 1440
TEMP_LINK_DEFAULT_MINUTES = ENV["TEMP_LINK_DEFAULT_MINUTES"]?.try(&.to_i) || TEMP_LINK_MAX_MINUTES
Expand Down
7 changes: 7 additions & 0 deletions src/placeos-rest-api/controllers/application.cr
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,13 @@ module PlaceOS::Api
ImageGenError.new(error.message || "image generation failed", error.kind)
end

@[AC::Route::Exception(Error::BadGateway, status_code: HTTP::Status::BAD_GATEWAY)]
@[AC::Route::Exception(Error::GatewayTimeout, status_code: HTTP::Status::GATEWAY_TIMEOUT)]
def upstream_failed(error) : CommonError
Log.warn(exception: error) { error.message }
CommonError.new(error, false)
end

# 406 when a request cannot be satisfied (e.g. no approvers available)
@[AC::Route::Exception(Error::NotAcceptable, status_code: HTTP::Status::NOT_ACCEPTABLE)]
def resource_not_acceptable(error) : CommonError
Expand Down
55 changes: 55 additions & 0 deletions src/placeos-rest-api/controllers/uploads.cr
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,61 @@ module PlaceOS::Api
end
end

SCREENSHOT_TAG = "screenshot"

record ScreenshotInfo, url : String, width : Int32 = 1920, height : Int32 = 1080, scale : Float64 = 1.0,
format : Screenshot::Format = Screenshot::Format::Png, full_page : Bool = false,
settle : Int32 = Screenshot::DEFAULT_SETTLE_MS, file_name : String? = nil, public : Bool = false,
tags : Array(String) = [] of String do
include JSON::Serializable
end

# render a web page in a headless browser and store the image as an upload.
#
# waits for the network to go (almost) idle, web fonts to load and a frame
# to paint, then a further `settle` milliseconds for animations.
# Only `https` pages are rendered and any plain `http` load they attempt is
# blocked, which keeps the browser away from internal services.
@[AC::Route::POST("/screenshot", body: :info, status_code: HTTP::Status::CREATED)]
def screenshot(info : ScreenshotInfo) : ::PlaceOS::Model::Upload
uri = URI.parse(info.url)
unless uri.scheme.try(&.downcase) == "https" && uri.host.presence
raise AC::Route::Param::ValueError.new("must be an absolute https URL", "url")
end
{
{"width", info.width, 1, Screenshot::MAX_WIDTH},
{"height", info.height, 1, Screenshot::MAX_HEIGHT},
{"settle", info.settle, 0, Screenshot::MAX_SETTLE_MS},
}.each do |(name, value, min, max)|
raise AC::Route::Param::ValueError.new("must be between #{min} and #{max}", name) unless min <= value <= max
end
unless Screenshot::MIN_SCALE <= info.scale <= Screenshot::MAX_SCALE
raise AC::Route::Param::ValueError.new("must be between #{Screenshot::MIN_SCALE} and #{Screenshot::MAX_SCALE}", "scale")
end
info.tags.each do |tag|
unless tag.match(TAG_ALLOW_REGEX)
raise AC::Route::Param::ValueError.new("Invalid tag (only letters, digits and .!#$%&'*+-/=?^_`{|}~@ allowed): #{tag}", "tags")
end
end

format = info.format
file_name = sanitize_filename(info.file_name.presence || "screenshot-#{uri.host}-#{info.width}x#{info.height}.#{format.extension}")
allowed?(file_name, format.mime)

image = Screenshot.capture(uri, info.width, info.height, info.scale, format, info.full_page, info.settle)

ObjectStore.put(
image,
format.mime,
storage,
current_user,
file_name: file_name,
object_key: get_object_key(file_name),
public: info.public,
tags: (info.tags + [SCREENSHOT_TAG]).uniq,
)
end

protected def generate_temp_url(expiry : Int32 = TEMP_LINK_DEFAULT_MINUTES)
max_expiry = TEMP_LINK_MAX_MINUTES
expiry = expiry > max_expiry ? max_expiry : expiry
Expand Down
Loading
Loading