♻️ Refactor exporter

- Migrate from puppeteer to playwright
- Fix many lifecycle and resource usage issues
- Add redis integration
- Enable multiple exportation
- Enable asynchronos exportation (with progress reporting)
This commit is contained in:
Andrey Antukh 2022-03-18 12:34:02 +01:00 committed by Alonso Torres
parent f0a9889f33
commit 4a9e38a221
21 changed files with 1366 additions and 1017 deletions

View file

@ -7,7 +7,7 @@
(ns app.browser
(:require
["generic-pool" :as gp]
["puppeteer-core" :as pp]
["playwright" :as pw]
[app.common.data :as d]
[app.common.logging :as l]
[app.common.uuid :as uuid]
@ -20,78 +20,72 @@
;; --- BROWSER API
(def default-timeout 30000)
(def default-viewport {:width 1920 :height 1080 :scale 1})
(def default-viewport-width 1920)
(def default-viewport-height 1080)
(def default-user-agent
(str "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36"))
"(KHTML, like Gecko) Chrome/99.0.3729.169 Safari/537.36"))
(defn set-cookie!
[page {:keys [key value domain]}]
(.setCookie ^js page #js {:name key
:value value
:domain domain}))
(defn create-cookies
[uri {:keys [name token] :or {name "auth-token"}}]
(let [domain (str (:host uri)
(when (:port uri)
(str ":" (:port uri))))]
#js [#js {:domain domain
:path "/"
:name name
:value token}]))
(defn configure-page!
[page {:keys [timeout cookie user-agent viewport]}]
(let [timeout (or timeout default-timeout)
user-agent (or user-agent default-user-agent)
viewport (merge default-viewport viewport)]
(p/do!
(.setViewport ^js page #js {:width (:width viewport)
:height (:height viewport)
:deviceScaleFactor (:scale viewport)})
(.setUserAgent ^js page user-agent)
(.setDefaultTimeout ^js page timeout)
(when cookie
(set-cookie! page cookie)))))
(defn navigate!
([page url] (navigate! page url nil))
([page url {:keys [wait-until]
:or {wait-until "networkidle2"}}]
(.goto ^js page url #js {:waitUntil wait-until})))
(defn nav!
([page url] (nav! page url nil))
([page url {:keys [wait-until timeout] :or {wait-until "networkidle" timeout 20000}}]
(.goto ^js page (str url) #js {:waitUntil wait-until :timeout timeout})))
(defn sleep
[page ms]
(.waitForTimeout ^js page ms))
(defn wait-for
([page selector] (wait-for page selector nil))
([page selector {:keys [visible timeout] :or {visible false timeout 10000}}]
(.waitForSelector ^js page selector #js {:visible visible})))
([locator] (wait-for locator nil))
([locator {:keys [state timeout] :or {state "visible" timeout 10000}}]
(.waitFor ^js locator #js {:state state :timeout timeout})))
(defn screenshot
([frame] (screenshot frame nil))
([frame {:keys [full-page? omit-background? type]
:or {type "png"
full-page? false
omit-background? false}}]
([frame] (screenshot frame {}))
([frame {:keys [full-page? omit-background? type quality]
:or {type "png" full-page? false omit-background? false quality 95}}]
(let [options (-> (obj/new)
(obj/set! "type" (name type))
(obj/set! "omitBackground" omit-background?)
(cond-> full-page? (-> (obj/set! "fullPage" true)
(obj/set! "clip" nil))))]
(cond-> (= "jpeg" type) (obj/set! "quality" quality))
(cond-> full-page? (-> (obj/set! "fullPage" true)
(obj/set! "clip" nil))))]
(.screenshot ^js frame options))))
(defn emulate-media!
[page {:keys [media]}]
(.emulateMedia ^js page #js {:media media})
page)
(defn pdf
([page] (pdf page nil))
([page {:keys [viewport save-path]}]
(p/let [viewport (d/merge default-viewport viewport)]
(.emulateMediaType ^js page "screen")
(.pdf ^js page #js {:path save-path
:width (:width viewport)
:height (:height viewport)
:scale (:scale viewport)
:printBackground true
:preferCSSPageSize true}))))
([page] (pdf page {}))
([page {:keys [width height scale save-path]
:or {width default-viewport-width
height default-viewport-height
scale 1}}]
(.pdf ^js page #js {:path save-path
:width width
:height height
:scale scale
:printBackground true
:preferCSSPageSize true})))
(defn eval!
[frame f]
(.evaluate ^js frame f))
(defn select
[frame selector]
(.$ ^js frame selector))
(.locator ^js frame selector))
(defn select-all
[frame selector]
@ -103,23 +97,14 @@
(defonce pool (atom nil))
(defonce pool-browser-id (atom 1))
(def default-chrome-args
#js ["--no-sandbox"
"--font-render-hinting=none"
"--disable-setuid-sandbox"
"--disable-accelerated-2d-canvas"
"--disable-gpu"])
(def browser-pool-factory
(letfn [(create []
(let [path (cf/get :browser-executable-path "/usr/bin/google-chrome")]
(-> (pp/launch #js {:executablePath path :args default-chrome-args})
(p/then (fn [browser]
(let [id (deref pool-browser-id)]
(l/info :origin "factory" :action "create" :browser-id id)
(unchecked-set browser "__id" id)
(swap! pool-browser-id inc)
browser))))))
(p/let [browser (.launch pw/chromium)
id (swap! pool-browser-id inc)]
(l/info :origin "factory" :action "create" :browser-id id)
(unchecked-set browser "__id" id)
browser))
(destroy [obj]
(let [id (unchecked-get obj "__id")]
(l/info :origin "factory" :action "destroy" :browser-id id)
@ -137,14 +122,13 @@
(defn init
[]
(l/info :msg "initializing browser pool")
(let [opts #js {:max (cf/get :browser-pool-max 3)
:min (cf/get :browser-pool-min 0)
(let [opts #js {:max (cf/get :exporter-browser-pool-max 5)
:min (cf/get :exporter-browser-pool-min 0)
:testOnBorrow true
:evictionRunIntervalMillis 5000
:numTestsPerEvictionRun 5
:acquireTimeoutMillis 120000 ; 2min
:idleTimeoutMillis 10000}]
(reset! pool (gp/createPool browser-pool-factory opts))
(p/resolved nil)))
@ -152,73 +136,40 @@
[]
(when-let [pool (deref pool)]
(l/info :msg "finalizing browser pool")
(-> (.drain ^js pool)
(p/then (fn [] (.clear ^js pool))))))
(p/do!
(.drain ^js pool)
(.clear ^js pool))))
(defn- ex-ignore
[p]
(p/handle p (constantly nil)))
(defn exec!
[callback]
(letfn [(release-browser [pool browser]
(let [id (unchecked-get browser "__id")]
(-> (p/do! (.release ^js pool browser))
(p/handle (fn [res err]
(l/trace :action "exec:release-browser" :browser-id id)
(when err (js/console.log err))
(if err
(p/rejected err)
(p/resolved res)))))))
[config handle]
(letfn [(handle-browser [browser]
(p/let [id (unchecked-get browser "__id")
context (.newContext ^js browser config)]
(l/trace :hint "exec:handle:start" :browser-id id)
(p/let [page (.newPage ^js context)
result (handle page)]
(.close ^js context)
(l/trace :hint "exec:handle:end" :browser-id id)
result)))
(destroy-browser [pool browser]
(let [id (unchecked-get browser "__id")]
(-> (p/do! (.destroy ^js pool browser))
(p/handle (fn [res err]
(l/trace :action "exec:destroy-browser" :browser-id id)
(when err (js/console.log err))
(if err
(p/rejected err)
(p/resolved res)))))))
(handle-error [pool browser obj err]
(let [id (unchecked-get browser "__id")]
(if err
(do
(l/trace :action "exec:handle-error" :browser-id id)
(-> (p/do! (destroy-browser pool browser))
(p/handle #(p/rejected err))))
(p/resolved obj))))
(on-result [pool browser context result]
(let [id (unchecked-get browser "__id")]
(l/trace :action "exec:on-result" :browser-id id)
(-> (p/do! (.close ^js context))
(p/handle (fn [_ err]
(if err
(destroy-browser pool browser)
(release-browser pool browser))))
(p/handle #(p/resolved result)))))
(on-page [pool browser context page]
(let [id (unchecked-get browser "__id")]
(l/trace :action "exec:on-page" :browser-id id)
(-> (p/do! (callback page))
(p/handle (partial handle-error pool browser))
(p/then (partial on-result pool browser context)))))
(on-context [pool browser ctx]
(let [id (unchecked-get browser "__id")]
(l/trace :action "exec:on-context" :browser-id id)
(-> (p/do! (.newPage ^js ctx))
(p/handle (partial handle-error pool browser))
(p/then (partial on-page pool browser ctx)))))
(on-acquire [pool browser err]
(let [id (unchecked-get browser "__id")]
(l/trace :action "exec:on-acquire" :browser-id id)
(if err
(js/console.log err)
(-> (p/do! (.createIncognitoBrowserContext ^js browser))
(p/handle (partial handle-error pool browser))
(p/then (partial on-context pool browser))))))]
(on-acquire [pool browser]
(-> (handle-browser browser)
(p/then (fn [result]
(.release ^js pool browser)
result))
(p/catch (fn [cause]
(p/do!
(ex-ignore (.destroy ^js pool browser))
(p/rejected cause))))))
]
(when-let [pool (deref pool)]
(-> (p/do! (.acquire ^js pool))
(p/handle (partial on-acquire pool))))))
(p/then (partial on-acquire pool))
(p/catch (fn [cause]
(js/console.log "KKK" cause)
(p/rejected cause)))))))

View file

@ -13,38 +13,41 @@
[app.common.data :as d]
[app.common.spec :as us]
[app.common.version :as v]
[app.common.uri :as u]
[cljs.core :as c]
[cljs.pprint]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[lambdaisland.uri :as u]))
[cuerdas.core :as str]))
(def defaults
{:public-uri "http://localhost:3449"
:tenant "dev"
:host "devenv"
:http-server-port 6061
:browser-concurrency 5
:browser-strategy :incognito})
:http-server-host "localhost"
:redis-uri "redis://redis/0"
:exporter-domain-whitelist #{"localhost2:3449"}})
(s/def ::browser-concurrency ::us/integer)
(s/def ::browser-executable-path ::us/string)
(s/def ::browser-strategy ::us/keyword)
(s/def ::http-server-port ::us/integer)
(s/def ::public-uri ::us/string)
(s/def ::sentry-dsn ::us/string)
(s/def ::http-server-host ::us/string)
(s/def ::public-uri ::us/uri)
(s/def ::tenant ::us/string)
(s/def ::host ::us/string)
(s/def ::exporter-domain-whitelist ::us/set-of-str)
(s/def ::exporter-browser-pool-max ::us/integer)
(s/def ::exporter-browser-pool-min ::us/integer)
(s/def ::config
(s/keys :opt-un [::public-uri
::sentry-dsn
::host
::tenant
::http-server-port
::browser-concurrency
::browser-strategy
::browser-executable-path]))
::http-server-host
::exporter-browser-pool-max
::exporter-browser-pool-min
::exporter-domain-whitelist]))
(defn- read-env
[prefix]
(let [env (unchecked-get process "env")
@ -62,10 +65,14 @@
(defn- prepare-config
[]
(let [env (read-env "penpot")
env (d/without-nils env)
data (merge defaults env)]
(us/conform ::config data)))
(try
(let [env (read-env "penpot")
env (d/without-nils env)
data (merge defaults env)]
(us/conform ::config data))
(catch :default cause
(js/console.log (us/pretty-explain (ex-data cause)))
(throw cause))))
(def config
(atom (prepare-config)))

View file

@ -6,24 +6,23 @@
(ns app.core
(:require
["process" :as proc]
[app.browser :as bwr]
[app.redis :as redis]
[app.common.logging :as l]
[app.config]
[app.http :as http]
[app.sentry :as sentry]
[promesa.core :as p]))
(enable-console-print!)
(l/initialize!)
(sentry/init!)
(defonce state (atom nil))
(defn start
[& args]
(l/info :msg "initializing")
(p/do!
(bwr/init)
(redis/init)
(http/init)))
(def main start)
@ -36,5 +35,9 @@
(l/info :msg "stoping")
(p/do!
(bwr/stop)
(redis/stop)
(http/stop)
(done)))
(proc/on "uncaughtException" (fn [cause]
(js/console.error cause)))

View file

@ -0,0 +1,98 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.handlers
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.common.uri :as u]
[app.config :as cf]
[app.handlers.export-frames :as export-frames]
[app.handlers.export-shapes :as export-shapes]
[app.handlers.resources :as resources]
[app.util.transit :as t]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[promesa.core :as p]
[reitit.core :as r]))
(l/set-level! :info)
(defn on-error
[error exchange]
(let [{:keys [type message code] :as data} (ex-data error)]
(cond
(or (= :validation type)
(= :assertion type))
(let [explain (us/pretty-explain data)
data (-> data
(assoc :explain explain)
(dissoc ::s/problems ::s/value ::s/spec))]
(-> exchange
(assoc :response/status 400)
(assoc :response/body (t/encode data))
(assoc :response/headers {"content-type" "application/transit+json"})))
(= :not-found type)
(-> exchange
(assoc :response/status 404)
(assoc :response/body (t/encode data))
(assoc :response/headers {"content-type" "application/transit+json"}))
(and (= :internal type)
(= :browser-not-ready code))
(-> exchange
(assoc :response/status 503)
(assoc :response/body (t/encode data))
(assoc :response/headers {"content-type" "application/transit+json"}))
:else
(do
(l/error :msg "Unexpected error" :cause error)
(-> exchange
(assoc :response/status 500)
(assoc :response/body (t/encode data))
(assoc :response/headers {"content-type" "application/transit+json"}))))))
(defmulti command-spec :cmd)
(s/def ::id ::us/string)
(s/def ::uri ::us/uri)
(s/def ::wait ::us/boolean)
(s/def ::cmd ::us/keyword)
(defmethod command-spec :export-shapes [_] ::export-shapes/params)
(defmethod command-spec :export-frames [_] ::export-frames/params)
(defmethod command-spec :get-resource [_] (s/keys :req-un [::id]))
(s/def ::params
(s/and (s/keys :req-un [::cmd]
:opt-un [::wait ::uri])
(s/multi-spec command-spec :cmd)))
(defn validate-uri!
[uri]
(let [white-list (cf/get :exporter-domain-whitelist #{})
default (cf/get :public-uri)]
(when-not (or (contains? white-list (u/get-domain uri))
(= (u/get-domain default) (u/get-domain uri)))
(ex/raise :type :validation
:code :domain-not-allowed
:hint "looks like the uri provided is not part of the white list"))))
(defn handler
[{:keys [:request/params] :as exchange}]
(let [{:keys [cmd uri] :as params} (us/conform ::params params)]
(some-> uri validate-uri!)
(case cmd
:get-resource (resources/handler exchange)
:export-shapes (export-shapes/handler exchange params)
:export-frames (export-frames/handler exchange params)
(ex/raise :type :internal
:code :method-not-implemented
:hint (dm/fmt "method % not implemented" cmd)))))

View file

@ -0,0 +1,152 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.handlers.export-frames
(:require
["path" :as path]
[app.common.data.macros :as dm]
[app.common.exceptions :as exc :include-macros true]
[app.common.spec :as us]
[app.handlers.resources :as rsc]
[app.redis :as redis]
[app.renderer.pdf :as rp]
[app.util.shell :as sh]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[promesa.core :as p]))
(declare ^:private handle-export)
(declare ^:private create-pdf)
(declare ^:private export-frame)
(declare ^:private join-pdf)
(declare ^:private move-file)
(declare ^:private clean-tmp)
(s/def ::name ::us/string)
(s/def ::file-id ::us/uuid)
(s/def ::page-id ::us/uuid)
(s/def ::frame-id ::us/uuid)
(s/def ::uri ::us/uri)
(s/def ::export
(s/keys :req-un [::file-id ::page-id ::frame-id ::name]))
(s/def ::exports
(s/every ::export :kind vector? :min-count 1))
(s/def ::params
(s/keys :req-un [::exports]
:opt-un [::uri ::name]))
(defn handler
[{:keys [:request/auth-token] :as exchange} {:keys [exports uri] :as params}]
(let [xform (map #(assoc % :token auth-token :uri uri))
exports (sequence xform exports)]
(handle-export exchange (assoc params :exports exports))))
(defn handle-export
[exchange {:keys [exports wait uri name] :as params}]
(let [topic (-> exports first :file-id str)
resource (rsc/create :pdf (or name (-> exports first :name)))
on-progress (fn [progress]
(let [data {:type :export-update
:resource-id (:id resource)
:status "running"
:progress progress}]
(redis/pub! topic data)))
on-complete (fn [resource]
(let [data {:type :export-update
:resource-id (:id resource)
:size (:size resource)
:status "ended"}]
(redis/pub! topic data)))
on-error (fn [cause]
(let [data {:type :export-update
:resource-id (:id resource)
:status "error"
:cause (ex-message cause)}]
(redis/pub! topic data)))
proc (create-pdf :resource resource
:items exports
:on-progress on-progress
:on-complete on-complete
:on-error on-error)]
(if wait
(p/then proc #(assoc exchange :response/body (dissoc % :path)))
(assoc exchange :response/body (dissoc resource :path)))))
(defn create-pdf
[& {:keys [resource items on-progress on-complete on-error]
:or {on-progress identity
on-complete identity
on-error identity}}]
(p/let [progress (atom 0)
tmpdir (sh/create-tmpdir! "pdfexport")
file-id (-> items first :file-id)
items (into [] (map #(partial export-frame tmpdir %)) items)
xform (map (fn [export-fn]
#(p/finally
(export-fn)
(fn [result _]
(on-progress {:total (count items)
:done (swap! progress inc)
:name (:name result)})))))]
(-> (reduce (fn [res export-fn]
(p/let [res res
out (export-fn)]
(cons (:path out) res)))
(p/resolved nil)
(into '() xform items))
(p/then (partial join-pdf tmpdir file-id))
(p/then (partial move-file resource))
(p/then (partial clean-tmp tmpdir))
(p/then (fn [resource]
(-> (sh/stat (:path resource))
(p/then #(merge resource %)))))
(p/finally (fn [result cause]
(if cause
(on-error cause)
(on-complete result)))))))
(defn- export-frame
[tmpdir {:keys [file-id page-id frame-id token uri] :as params}]
(let [file-name (dm/fmt "%.pdf" frame-id)
save-path (path/join tmpdir file-name)]
(-> (rp/render {:name (dm/str frame-id)
:uri uri
:suffix ""
:token token
:file-id file-id
:page-id page-id
:object-id frame-id
:scale 1
:save-path save-path})
(p/then (fn [_]
{:name file-name
:path save-path})))))
(defn- join-pdf
[tmpdir file-id paths]
(let [output-path (path/join tmpdir (str file-id ".pdf"))
paths-str (str/join " " paths)]
(-> (sh/run-cmd! (str "pdfunite " paths-str " " output-path))
(p/then (constantly output-path)))))
(defn- move-file
[{:keys [path] :as resource} output-path]
(p/do
(sh/move! output-path path)
resource))
(defn- clean-tmp
[tdpath data]
(p/do!
(sh/rmdir! tdpath)
data))

View file

@ -0,0 +1,170 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.handlers.export-shapes
(:require
[app.common.exceptions :as exc :include-macros true]
[app.common.spec :as us]
[app.redis :as redis]
[app.handlers.resources :as rsc]
[app.renderer.bitmap :as rb]
[app.renderer.pdf :as rp]
[app.renderer.svg :as rs]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[promesa.core :as p]))
(declare ^:private handle-exports)
(declare ^:private handle-single-export)
(declare ^:private handle-multiple-export)
(declare ^:private run-export)
(declare ^:private assign-file-name)
(s/def ::name ::us/string)
(s/def ::page-id ::us/uuid)
(s/def ::file-id ::us/uuid)
(s/def ::object-id ::us/uuid)
(s/def ::scale ::us/number)
(s/def ::suffix ::us/string)
(s/def ::type ::us/keyword)
(s/def ::suffix string?)
(s/def ::scale number?)
(s/def ::uri ::us/uri)
(s/def ::profile-id ::us/uuid)
(s/def ::wait ::us/boolean)
(s/def ::export
(s/keys :req-un [::page-id ::file-id ::object-id ::type ::suffix ::scale ::name]))
(s/def ::exports
(s/coll-of ::export :kind vector? :min-count 1))
(s/def ::params
(s/keys :req-un [::exports ::profile-id]
:opt-un [::uri ::wait ::name]))
(defn handler
[{:keys [:request/auth-token] :as exchange} {:keys [exports] :as params}]
(let [xform (comp
(map #(assoc % :token auth-token))
(assign-file-name))
exports (into [] xform exports)]
(if (= 1 (count exports))
(handle-single-export exchange (assoc params :export (first exports)))
(handle-multiple-export exchange (assoc params :exports exports)))))
(defn- handle-single-export
[exchange {:keys [export wait uri profile-id name] :as params}]
(let [topic (str profile-id)
resource (rsc/create (:type export) (or name (:name export)))
on-progress (fn [progress]
(let [data {:type :export-update
:resource-id (:id resource)
:status "running"
:progress progress}]
(redis/pub! topic data)))
on-complete (fn [resource]
(let [data {:type :export-update
:resource-id (:id resource)
:size (:size resource)
:name (:name resource)
:status "ended"}]
(redis/pub! topic data)))
on-error (fn [cause]
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:status "error"
:cause (ex-message cause)}]
(redis/pub! topic data)))
proc (rsc/create-simple :task #(run-export export)
:resource resource
:on-progress on-progress
:on-error on-error
:on-complete on-complete)]
(if wait
(p/then proc #(assoc exchange :response/body (dissoc % :path)))
(assoc exchange :response/body (dissoc resource :path)))))
(defn- handle-multiple-export
[exchange {:keys [exports wait uri profile-id name] :as params}]
(let [tasks (map #(fn [] (run-export %)) exports)
topic (str profile-id)
resource (rsc/create :zip (or name (-> exports first :name)))
on-progress (fn [progress]
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:status "running"
:progress progress}]
(redis/pub! topic data)))
on-complete (fn [resource]
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:size (:size resource)
:status "ended"}]
(redis/pub! topic data)))
on-error (fn [cause]
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:status "error"
:cause (ex-message cause)}]
(redis/pub! topic data)))
proc (rsc/create-zip :resource resource
:tasks tasks
:on-progress on-progress
:on-complete on-complete
:on-error on-error)]
(if wait
(p/then proc #(assoc exchange :response/body (dissoc % :path)))
(assoc exchange :response/body (dissoc resource :path)))))
(defn- run-export
[{:keys [type] :as params}]
(p/let [res (case type
:png (rb/render params)
:jpeg (rb/render params)
:svg (rs/render params)
:pdf (rp/render params))]
(assoc res :type type)))
(defn- assign-file-name
"A transducer that assocs a candidate filename and avoid duplicates."
[]
(letfn [(find-candidate [params used]
(loop [index 0]
(let [candidate (str (:name params)
(:suffix params "")
(when (pos? index)
(str "-" (inc index)))
(case (:type params)
:png ".png"
:jpeg ".jpg"
:svg ".svg"
:pdf ".pdf"))]
(if (contains? used candidate)
(recur (inc index))
candidate))))]
(fn [rf]
(let [used (volatile! #{})]
(fn
([] (rf))
([result] (rf result))
([result params]
(let [candidate (find-candidate params @used)
params (assoc params :filename candidate)]
(vswap! used conj candidate)
(rf result params))))))))

View file

@ -0,0 +1,130 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.handlers.resources
"Temporal resouces management."
(:require
["archiver" :as arc]
["fs" :as fs]
["os" :as os]
["path" :as path]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.uuid :as uuid]
[app.util.shell :as sh]
[cljs.core :as c]
[cuerdas.core :as str]
[promesa.core :as p]))
(defn- get-path
[type id]
(path/join (os/tmpdir) (dm/str "exporter." (d/name type) "." id)))
(defn- get-mtype
[type]
(case (d/name type)
"zip" "application/zip"
"jpeg" "image/jpeg"
"png" "image/png"
"pdf" "application/pdf"))
(defn create
"Generates ephimeral resource object."
[type name]
(let [task-id (uuid/next)]
{:path (get-path type task-id)
:mtype (get-mtype type)
:name name
:id (dm/str (c/name type) "." task-id)}))
(defn- write-as-zip!
[{:keys [id path]} items on-progress]
(let [^js zip (arc/create "zip")
^js out (fs/createWriteStream path)
append! (fn [{:keys [data name] :as result}]
(.append zip data #js {:name name}))
progress (atom 0)]
(p/create
(fn [resolve reject]
(.on zip "error" #(reject %))
(.on zip "end" resolve)
(.on zip "entry" (fn [data]
(let [name (unchecked-get data "name")
num (swap! progress inc)]
#_(when (= 2 num)
(.abort ^js zip)
(reject (js/Error. "unable to create zip file")))
(on-progress
{:total (count items)
:done num}))))
(.pipe zip out)
(-> (reduce (fn [res export-fn]
(p/then res (fn [_] (-> (export-fn) (p/then append!)))))
(p/resolved 1)
items)
(p/then #(.finalize zip))
(p/catch reject))))))
(defn create-simple
[& {:keys [task resource on-progress on-complete on-error]
:or {on-progress identity
on-complete identity
on-error identity}
:as params}]
(let [path (:path resource)]
(-> (task)
(p/then (fn [{:keys [data name]}]
(on-progress {:total 1 :done 1 :name name})
(.writeFile fs/promises path data)))
(p/then #(sh/stat path))
(p/then #(merge resource %))
(p/finally (fn [result cause]
(if cause
(on-error cause)
(on-complete result)))))))
(defn create-zip
"Creates a resource with multiple files merget into a single zip file."
[& {:keys [resource tasks on-error on-progress on-complete]
:or {on-error identity
on-progress identity
on-complete identity}}]
(let [{:keys [path id] :as resource} resource]
(-> (write-as-zip! resource tasks on-progress)
(p/then #(sh/stat path))
(p/then #(merge resource %))
(p/finally (fn [result cause]
(if cause
(on-error cause)
(on-complete result)))))))
(defn- lookup
[id]
(p/let [[type task-id] (str/split id "." 2)
path (get-path type task-id)
mtype (get-mtype type)
stat (sh/stat path)]
(when-not stat
(ex/raise :type :not-found))
{:stream (fs/createReadStream path)
:headers {"content-type" mtype
"content-length" (:size stat)}}))
(defn handler
[{:keys [:request/params response] :as exchange}]
(when-not (contains? params :id)
(ex/raise :type :validation
:code :missing-id))
(-> (lookup (get params :id))
(p/then (fn [{:keys [stream headers] :as resource}]
(-> exchange
(assoc :response/status 200)
(assoc :response/body stream)
(assoc :response/headers headers))))))

View file

@ -6,67 +6,162 @@
(ns app.http
(:require
["cookies" :as Cookies]
["http" :as http]
["inflation" :as inflate]
["raw-body" :as raw-body]
["stream" :as stream]
[app.common.logging :as l]
[app.common.spec :as us]
[app.common.transit :as t]
[app.config :as cf]
[app.http.export :refer [export-handler]]
[app.http.export-frames :refer [export-frames-handler]]
[app.http.impl :as impl]
[app.sentry :as sentry]
[app.util.transit :as t]
[app.handlers :as handlers]
[cuerdas.core :as str]
[promesa.core :as p]
[reitit.core :as r]))
[lambdaisland.uri :as u]
[promesa.core :as p]))
(l/set-level! :info)
(def routes
[["/export-frames" {:handler export-frames-handler}]
["/export" {:handler export-handler}]])
(defprotocol IStreamableResponseBody
(write-body! [_ response]))
(extend-protocol IStreamableResponseBody
string
(write-body! [data response]
(.write ^js response data)
(.end ^js response))
js/Buffer
(write-body! [data response]
(.write ^js response data)
(.end ^js response))
stream/Stream
(write-body! [data response]
(.pipe ^js data response)
(.on ^js data "error" (fn [cause]
(js/console.error cause)
(.end response)))))
(defn- handle-response
[{:keys [:response/body
:response/headers
:response/status
response]
:as exchange}]
(let [status (or status 200)
headers (clj->js headers)
body (or body "")]
(.writeHead ^js response status headers)
(write-body! body response)))
(defn- parse-headers
[req]
(let [orig (unchecked-get req "headers")]
(persistent!
(reduce #(assoc! %1 (str/lower %2) (unchecked-get orig %2))
(transient {})
(js/Object.keys orig)))))
(defn- wrap-body-params
[handler]
(let [opts #js {:limit "2mb" :encoding "utf8"}]
(fn [{:keys [:request/method :request/headers request] :as exchange}]
(let [ctype (get headers "content-type")]
(if (= method "post")
(-> (raw-body (inflate request) opts)
(p/then (fn [data]
(cond-> data
(= ctype "application/transit+json")
(t/decode-str))))
(p/then (fn [data]
(handler (assoc exchange :request/body-params data)))))
(handler exchange))))))
(defn- wrap-params
[handler]
(fn [{:keys [:request/body-params :request/query-params] :as exchange}]
(handler (assoc exchange :request/params (merge query-params body-params)))))
(defn- wrap-response-format
[handler]
(fn [exchange]
(p/then
(handler exchange)
(fn [{:keys [:response/body :response/status] :as exchange}]
(cond
(map? body)
(let [data (t/encode-str body {:type :json-verbose})]
(-> exchange
(assoc :response/body data)
(assoc :response/status 200)
(update :response/headers assoc "content-type" "application/transit+json")
(update :response/headers assoc "content-length" (count data))))
(and (nil? body)
(= 200 status))
(-> exchange
(assoc :response/body "")
(assoc :response/status 204)
(assoc :response/headers {"content-length" 0}))
:else
exchange)))))
(defn- wrap-query-params
[handler]
(fn [{:keys [:request/uri] :as exchange}]
(handler (assoc exchange :request/query-params (u/query-string->map (:query uri))))))
(defn- wrap-error
[handler on-error]
(fn [exchange]
(-> (p/do (handler exchange))
(p/catch (fn [cause] (on-error cause exchange))))))
(defn- wrap-auth
[handler cookie-name]
(fn [{:keys [:request/cookies] :as exchange}]
(let [token (.get ^js cookies cookie-name)]
(handler (cond-> exchange token (assoc :request/auth-token token))))))
(defn- create-adapter
[handler]
(fn [req res]
(let [cookies (Cookies. req res)
headers (parse-headers req)
uri (u/uri (unchecked-get req "url"))
exchange {:request/method (str/lower (unchecked-get req "method"))
:request/path (:path uri)
:request/uri uri
:request/headers headers
:request/cookies cookies
:request req
:response res}]
(-> (p/do (handler exchange))
(p/then handle-response)))))
(defn- create-server
[handler]
(.createServer ^js http (create-adapter handler)))
(def instance (atom nil))
(defn- on-error
[error request]
(let [{:keys [type message code] :as data} (ex-data error)]
(sentry/capture-exception error {::sentry/request request
:ex-data data})
(cond
(= :validation type)
(let [header (get-in request [:headers "accept"])]
(if (and (str/starts-with? header "text/html")
(= :spec-validation (:code data)))
{:status 400
:headers {"content-type" "text/html"}
:body (str "<pre style='font-size:16px'>" (:explain data) "</pre>\n")}
{:status 400
:headers {"content-type" "text/html"}
:body (str "<pre style='font-size:16px'>" (:explain data) "</pre>\n")}))
(and (= :internal type)
(= :browser-not-ready code))
{:status 503
:headers {"x-error" (t/encode data)}
:body ""}
:else
(do
(l/error :msg "Unexpected error" :error error)
(js/console.error error)
{:status 500
:headers {"x-error" (t/encode data)}
:body ""}))))
(defn init
[]
(let [router (r/router routes)
handler (impl/router-handler router)
server (impl/server handler on-error)
(let [handler (-> handlers/handler
(wrap-auth "auth-token")
(wrap-response-format)
(wrap-params)
(wrap-query-params)
(wrap-body-params)
(wrap-error handlers/on-error))
server (create-server handler)
port (cf/get :http-server-port 6061)]
(.listen server port)
(l/info :msg "welcome to penpot"
:module "exporter"
:version (:full @cf/version))
:module "exporter"
:version (:full @cf/version))
(l/info :msg "starting http server" :port port)
(reset! instance server)))

View file

@ -1,125 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.http.export
(:require
[app.common.exceptions :as exc :include-macros true]
[app.common.spec :as us]
[app.renderer.bitmap :as rb]
[app.renderer.pdf :as rp]
[app.renderer.svg :as rs]
[app.zipfile :as zip]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[promesa.core :as p]))
(s/def ::name ::us/string)
(s/def ::page-id ::us/uuid)
(s/def ::file-id ::us/uuid)
(s/def ::object-id ::us/uuid)
(s/def ::scale ::us/number)
(s/def ::suffix ::us/string)
(s/def ::type ::us/keyword)
(s/def ::suffix string?)
(s/def ::scale number?)
(s/def ::export (s/keys :req-un [::type ::suffix ::scale]))
(s/def ::exports (s/coll-of ::export :kind vector?))
(s/def ::handler-params
(s/keys :req-un [::page-id ::file-id ::object-id ::name ::exports]))
(declare handle-single-export)
(declare handle-multiple-export)
(declare perform-export)
(declare attach-filename)
(defn export-handler
[{:keys [params cookies] :as request}]
(let [{:keys [exports page-id file-id object-id name]} (us/conform ::handler-params params)
token (.get ^js cookies "auth-token")]
(case (count exports)
0 (exc/raise :type :validation
:code :missing-exports)
1 (-> (first exports)
(assoc :name name)
(assoc :token token)
(assoc :file-id file-id)
(assoc :page-id page-id)
(assoc :object-id object-id)
(handle-single-export))
(->> exports
(map (fn [item]
(-> item
(assoc :name name)
(assoc :token token)
(assoc :file-id file-id)
(assoc :page-id page-id)
(assoc :object-id object-id))))
(handle-multiple-export)))))
(defn- handle-single-export
[params]
(p/let [result (perform-export params)]
{:status 200
:body (:content result)
:headers {"content-type" (:mime-type result)
"content-length" (:length result)}}))
(defn- handle-multiple-export
[exports]
(let [proms (->> exports
(attach-filename)
(map perform-export))]
(-> (p/all proms)
(p/then (fn [results]
(reduce #(zip/add! %1 (:filename %2) (:content %2)) (zip/create) results)))
(p/then (fn [fzip]
(.generateAsync ^js fzip #js {:type "uint8array"})))
(p/then (fn [data]
{:status 200
:headers {"content-type" "application/zip"}
:body data})))))
(defn- perform-export
[params]
(case (:type params)
:png (rb/render params)
:jpeg (rb/render params)
:svg (rs/render params)
:pdf (rp/render params)))
(defn- find-filename-candidate
[params used]
(loop [index 0]
(let [candidate (str (:name params)
(:suffix params "")
(when (pos? index)
(str "-" (inc index)))
(case (:type params)
:png ".png"
:jpeg ".jpg"
:svg ".svg"
:pdf ".pdf"))]
(if (contains? used candidate)
(recur (inc index))
candidate))))
(defn- attach-filename
[exports]
(loop [exports (seq exports)
used #{}
result []]
(if (nil? exports)
result
(let [export (first exports)
candidate (find-filename-candidate export used)
export (assoc export :filename candidate)]
(recur (next exports)
(conj used candidate)
(conj result export))))))

View file

@ -1,73 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.http.export-frames
(:require
["path" :as path]
[app.common.exceptions :as exc :include-macros true]
[app.common.spec :as us]
[app.renderer.pdf :as rp]
[app.util.shell :as sh]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[promesa.core :as p]))
(s/def ::name ::us/string)
(s/def ::file-id ::us/uuid)
(s/def ::page-id ::us/uuid)
(s/def ::frame-id ::us/uuid)
(s/def ::frame-ids (s/coll-of ::frame-id :kind vector?))
(s/def ::handler-params
(s/keys :req-un [::file-id ::page-id ::frame-ids]))
(defn- export-frame
[tdpath file-id page-id token frame-id spaths]
(p/let [spath (path/join tdpath (str frame-id ".pdf"))
result (rp/render {:name (str frame-id)
:suffix ""
:token token
:file-id file-id
:page-id page-id
:object-id frame-id
:scale 1
:save-path spath})]
(conj spaths spath)))
(defn- join-files
[tdpath file-id paths]
(let [output-path (path/join tdpath (str file-id ".pdf"))
paths-str (str/join " " paths)]
(-> (sh/run-cmd! (str "pdfunite " paths-str " " output-path))
(p/then (constantly output-path)))))
(defn- clean-tmp-data
[tdpath data]
(p/do!
(sh/rmdir! tdpath)
data))
(defn export-frames-handler
[{:keys [params cookies] :as request}]
(let [{:keys [name file-id page-id frame-ids]} (us/conform ::handler-params params)
token (.get ^js cookies "auth-token")]
(if (seq frame-ids)
(p/let [tdpath (sh/create-tmpdir! "pdfexport-")
data (-> (reduce (fn [promise frame-id]
(p/then promise (partial export-frame tdpath file-id page-id token frame-id)))
(p/future [])
(reverse frame-ids))
(p/then (partial join-files tdpath file-id))
(p/then sh/read-file)
(p/then (partial clean-tmp-data tdpath)))]
{:status 200
:body data
:headers {"content-type" "application/pdf"
"content-length" (.-length data)}})
{:status 204
:body ""
:headers {"content-type" "text/plain"}})))

View file

@ -1,95 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.http.impl
(:require
["http" :as http]
["cookies" :as Cookies]
["inflation" :as inflate]
["raw-body" :as raw-body]
[app.util.transit :as t]
[cuerdas.core :as str]
[lambdaisland.uri :as u]
[promesa.core :as p]
[reitit.core :as r]))
(def methods-with-body
#{"POST" "PUT" "DELETE"})
(defn- match
[router {:keys [path query] :as request}]
(when-let [match (r/match-by-path router path)]
(assoc match :query-params (u/query-string->map query))))
(defn- handle-response
[req res]
(fn [{:keys [body headers status] :or {headers {} status 200}}]
(.writeHead ^js res status (clj->js headers))
(.end ^js res body)))
(defn- parse-headers
[req]
(let [orig (unchecked-get req "headers")]
(persistent!
(reduce #(assoc! %1 %2 (unchecked-get orig %2))
(transient {})
(js/Object.keys orig)))))
(defn- parse-body
[req]
(let [headers (unchecked-get req "headers")
method (unchecked-get req "method")
ctype (unchecked-get headers "content-type")
opts #js {:limit "5mb" :encoding "utf8"}]
(when (contains? methods-with-body method)
(-> (raw-body (inflate req) opts)
(p/then (fn [data]
(cond-> data
(= ctype "application/transit+json")
(t/decode))))))))
(defn- handler-adapter
[handler on-error]
(fn [req res]
(let [cookies (new Cookies req res)
headers (parse-headers req)
uri (u/uri (unchecked-get req "url"))
request {:method (str/lower (unchecked-get req "method"))
:path (:path uri)
:query (:query uri)
:url uri
:headers headers
:cookies cookies
:internal-request req
:internal-response res}]
(-> (parse-body req)
(p/then (fn [body]
(let [request (assoc request :body body)]
(handler request))))
(p/catch (fn [error] (on-error error request)))
(p/then (handle-response req res))))))
(defn router-handler
[router]
(fn [{:keys [body] :as request}]
(let [route (match router request)
params (merge {}
(:query-params route)
(:path-params route)
(when (map? body) body))
request (assoc request
:route route
:params params)
handler (get-in route [:data :handler])]
(if (and route handler)
(handler request)
{:status 404
:body "Not found"}))))
(defn server
[handler on-error]
(.createServer ^js http (handler-adapter handler on-error)))

View file

@ -0,0 +1,54 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.redis
(:require
["ioredis" :as redis]
[app.common.data.macros :as dm]
[app.common.logging :as l]
[app.common.transit :as t]
[app.config :as cf]))
(l/set-level! :trace)
(def client (atom nil))
(defn- create-client
[uri]
(let [^js client (new redis uri)]
(.on client "connect"
(fn [] (l/info :hint "redis connection established" :uri uri)))
(.on client "error"
(fn [cause] (l/error :hint "error on redis connection" :cause cause)))
(.on client "close"
(fn [] (l/warn :hint "connection closed")))
(.on client "reconnect"
(fn [ms] (l/warn :hint "reconnecting to redis" :ms ms)))
(.on client "end"
(fn [ms] (l/warn :hint "client ended, no more connections will be attempted")))
client))
(defn init
[]
(swap! client (fn [prev]
(when prev (.disconnect ^js prev))
(create-client (cf/get :redis-uri)))))
(defn stop
[]
(swap! client (fn [client]
(when client (.quit ^js client))
nil)))
(def ^:private tenant (cf/get :tenant))
(defn pub!
[topic payload]
(let [payload (if (map? payload) (t/encode-str payload) payload)
topic (dm/str tenant "." topic)]
(when-let [client @client]
(.publish ^js client topic payload))))

View file

@ -16,46 +16,34 @@
[app.config :as cf]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[lambdaisland.uri :as u]
[promesa.core :as p]))
(defn create-cookie
[uri token]
(let [domain (str (:host uri)
(when (:port uri)
(str ":" (:port uri))))]
{:domain domain
:key "auth-token"
:value token}))
(defn screenshot-object
[{:keys [file-id page-id object-id token scale type]}]
(letfn [(handle [page]
(let [path (str "/render-object/" file-id "/" page-id "/" object-id)
uri (-> (u/uri (cf/get :public-uri))
(assoc :path "/")
(assoc :fragment path))
cookie (create-cookie uri token)]
(screenshot page (str uri) cookie)))
(screenshot [page uri cookie]
(l/info :uri uri)
(let [viewport {:width 1920
:height 1080
:scale scale}
options {:viewport viewport
:cookie cookie}]
(p/do!
(bw/configure-page! page options)
(bw/navigate! page uri)
(bw/eval! page (js* "() => document.body.style.background = 'transparent'"))
(bw/wait-for page "#screenshot")
(p/let [dom (bw/select page "#screenshot")]
(case type
:png (bw/screenshot dom {:omit-background? true :type type})
:jpeg (bw/screenshot dom {:omit-background? false :type type}))))))]
(bw/exec! handle)))
[{:keys [file-id page-id object-id token scale type uri]}]
(p/let [path (str "/render-object/" file-id "/" page-id "/" object-id)
uri (-> (or uri (cf/get :public-uri))
(assoc :path "/")
(assoc :fragment path))]
(bw/exec!
#js {:screen #js {:width bw/default-viewport-width
:height bw/default-viewport-height}
:viewport #js {:width bw/default-viewport-width
:height bw/default-viewport-height}
:locale "en-US"
:storageState #js {:cookies (bw/create-cookies uri {:token token})}
:deviceScaleFactor scale
:userAgent bw/default-user-agent}
(fn [page]
(l/info :uri uri)
(p/do!
(bw/nav! page (str uri))
(p/let [node (bw/select page "#screenshot")]
(bw/wait-for node)
(bw/eval! page (js* "() => document.body.style.background = 'transparent'"))
(bw/sleep page 2000) ; the good old fix with sleep
(case type
:png (bw/screenshot node {:omit-background? true :type type})
:jpeg (bw/screenshot node {:omit-background? false :type type}))))))))
(s/def ::name ::us/string)
(s/def ::suffix ::us/string)
@ -65,25 +53,32 @@
(s/def ::object-id ::us/uuid)
(s/def ::scale ::us/number)
(s/def ::token ::us/string)
(s/def ::filename ::us/string)
(s/def ::origin ::us/string)
(s/def ::uri ::us/uri)
(s/def ::render-params
(s/def ::params
(s/keys :req-un [::name ::suffix ::type ::object-id ::page-id ::scale ::token ::file-id]
:opt-un [::filename]))
:opt-un [::origin ::uri]))
(defn render
[params]
(us/assert ::render-params params)
(p/let [content (screenshot-object params)]
{:content content
:filename (or (:filename params)
(str (:name params)
(:suffix params "")
(case (:type params)
:png ".png"
:jpeg ".jpg")))
:length (alength content)
:mime-type (case (:type params)
:png "image/png"
:jpeg "image/jpeg")}))
(us/verify ::params params)
(when (and (:origin params)
(not (contains? (cf/get :origin-white-list) (:origin params))))
(ex/raise :type :validation
:code :invalid-origin
:hint "invalid origin"
:origin (:origin params)))
(p/let [content (screenshot-object params)]
{:data content
:name (str (:name params)
(:suffix params "")
(case (:type params)
:png ".png"
:jpeg ".jpg"))
:size (alength content)
:mtype (case (:type params)
:png "image/png"
:jpeg "image/jpeg")}))

View file

@ -13,46 +13,33 @@
[app.common.spec :as us]
[app.config :as cf]
[cljs.spec.alpha :as s]
[lambdaisland.uri :as u]
[promesa.core :as p]))
(defn create-cookie
[uri token]
(let [domain (str (:host uri)
(when (:port uri)
(str ":" (:port uri))))]
{:domain domain
:key "auth-token"
:value token}))
(defn pdf-from-object
[{:keys [file-id page-id object-id token scale type save-path]}]
(letfn [(handle [page]
(let [path (str "/render-object/" file-id "/" page-id "/" object-id)
uri (-> (u/uri (cf/get :public-uri))
(assoc :path "/")
(assoc :query "essential=t")
(assoc :fragment path))
cookie (create-cookie uri token)]
(pdf-from page (str uri) cookie)))
(pdf-from [page uri cookie]
(l/info :uri uri)
(p/let [options {:cookie cookie}]
(bw/configure-page! page options)
(bw/navigate! page uri)
(bw/wait-for page "#screenshot")
;; taking png screenshot before pdf, helps to make the
;; pdf rendering works as expected.
(p/let [dom (bw/select page "#screenshot")]
(bw/screenshot dom {:full-page? true}))
(if save-path
(bw/pdf page {:save-path save-path})
(bw/pdf page))))]
(bw/exec! handle)))
[{:keys [file-id page-id object-id token scale type save-path uri] :as params}]
(p/let [path (str "/render-object/" file-id "/" page-id "/" object-id)
uri (-> (or uri (cf/get :public-uri))
(assoc :path "/")
(assoc :fragment path))]
(bw/exec!
#js {:screen #js {:width bw/default-viewport-width
:height bw/default-viewport-height}
:viewport #js {:width bw/default-viewport-width
:height bw/default-viewport-height}
:locale "en-US"
:storageState #js {:cookies (bw/create-cookies uri {:token token})}
:deviceScaleFactor scale
:userAgent bw/default-user-agent}
(fn [page]
(l/info :uri uri)
(p/do!
(bw/nav! page uri)
(p/let [dom (bw/select page "#screenshot")]
(bw/wait-for dom)
(bw/screenshot dom {:full-page? true})
(if save-path
(bw/pdf page {:save-path save-path})
(bw/pdf page))))))))
(s/def ::name ::us/string)
(s/def ::suffix ::us/string)
@ -61,22 +48,21 @@
(s/def ::object-id ::us/uuid)
(s/def ::scale ::us/number)
(s/def ::token ::us/string)
(s/def ::filename ::us/string)
(s/def ::save-path ::us/string)
(s/def ::uri ::us/uri)
(s/def ::render-params
(s/keys :req-un [::name ::suffix ::object-id ::page-id ::scale ::token ::file-id]
:opt-un [::filename ::save-path]))
:opt-un [::save-path ::uri]))
(defn render
[params]
(us/assert ::render-params params)
(p/let [content (pdf-from-object params)]
{:content content
:filename (or (:filename params)
(str (:name params)
(:suffix params "")
".pdf"))
:length (alength content)
:mime-type "application/pdf"}))
{:data content
:name (str (:name params)
(:suffix params "")
".pdf")
:size (alength content)
:mtype "application/pdf"}))

View file

@ -15,12 +15,10 @@
[app.common.pages :as cp]
[app.common.spec :as us]
[app.config :as cf]
[app.renderer.bitmap :refer [create-cookie]]
[app.util.shell :as sh]
[cljs.spec.alpha :as s]
[clojure.walk :as walk]
[cuerdas.core :as str]
[lambdaisland.uri :as u]
[promesa.core :as p]))
(l/set-level! :trace)
@ -114,7 +112,7 @@
(defn- render-object
[{:keys [page-id file-id object-id token scale suffix type]}]
[{:keys [page-id file-id object-id token scale suffix type uri]}]
(letfn [(convert-to-ppm [pngpath]
(l/trace :fn :convert-to-ppm)
(let [basepath (path/dirname pngpath)
@ -306,7 +304,7 @@
(-> (bw/select-all page "#screenshot foreignObject")
(p/then (fn [nodes] (p/all (map (partial process-text-node page) nodes))))))
(extract-svg [page]
(extract [page]
(p/let [dom (bw/select page "#screenshot")
xmldata (bw/eval! dom (fn [elem] (.-outerHTML ^js elem)))
nodes (process-text-nodes page)
@ -322,35 +320,32 @@
;; (cljs.pprint/pprint (xml->clj result))
;; (println "-------")
result))
]
(render-in-page [page {:keys [uri cookie] :as rctx}]
(let [viewport {:width 1920
:height 1080
:scale 4}
options {:viewport viewport
:timeout 15000
:cookie cookie}]
(p/do!
(bw/configure-page! page options)
(bw/navigate! page uri)
(bw/wait-for page "#screenshot")
(bw/sleep page 2000)
;; (bw/eval! page (js* "() => document.body.style.background = 'transparent'"))
page)))
(handle [rctx page]
(p/let [page (render-in-page page rctx)]
(extract-svg page)))]
(let [path (str "/render-object/" file-id "/" page-id "/" object-id "?render-texts=true")
uri (-> (u/uri (cf/get :public-uri))
(p/let [path (str "/render-object/" file-id "/" page-id "/" object-id "?render-texts=true")
uri (-> (or uri (cf/get :public-uri))
(assoc :path "/")
(assoc :fragment path))
cookie (create-cookie uri token)
rctx {:cookie cookie
:uri (str uri)}]
(l/info :uri (:uri rctx))
(bw/exec! (partial handle rctx)))))
(assoc :fragment path))]
(bw/exec!
#js {:screen #js {:width bw/default-viewport-width
:height bw/default-viewport-height}
:viewport #js {:width bw/default-viewport-width
:height bw/default-viewport-height}
:locale "en-US"
:storageState #js {:cookies (bw/create-cookies uri {:token token})}
:deviceScaleFactor scale
:userAgent bw/default-user-agent}
(fn [page]
(l/info :uri uri)
(p/do!
(bw/nav! page uri)
(p/let [dom (bw/select page "#screenshot")]
(js/console.log "FFFF" dom)
(bw/wait-for dom)
(bw/sleep page 2000))
(extract page)))))))
(s/def ::name ::us/string)
(s/def ::suffix ::us/string)
@ -360,21 +355,20 @@
(s/def ::object-id ::us/uuid)
(s/def ::scale ::us/number)
(s/def ::token ::us/string)
(s/def ::filename ::us/string)
(s/def ::uri ::us/uri)
(s/def ::render-params
(s/def ::params
(s/keys :req-un [::name ::suffix ::type ::object-id ::page-id ::file-id ::scale ::token]
:opt-un [::filename]))
:opt-un [::uri]))
(defn render
[params]
(us/assert ::render-params params)
(us/assert ::params params)
(p/let [content (render-object params)]
{:content content
:filename (or (:filename params)
(str (:name params)
(:suffix params "")
".svg"))
:length (alength content)
:mime-type "image/svg+xml"}))
{:data content
:name (str (:name params)
(:suffix params "")
".svg")
:size (alength content)
:mtype "image/svg+xml"}))

View file

@ -1,44 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.sentry
(:require
["@sentry/node" :as sentry]
["@sentry/tracing" :as sentry-t]
[app.common.data :as d]
[app.config :as cf]))
(defn init!
[]
(when-let [dsn (cf/get :sentry-dsn)]
(sentry/init
#js {:dsn dsn
:release (str "frontend@" (:base @cf/version))
:serverName (cf/get :host)
:environment (cf/get :tenant)
:autoSessionTracking false
:attachStacktrace false
:maxBreadcrumbs 20
:tracesSampleRate 1.0})))
(def parse-request (unchecked-get sentry/Handlers "parseRequest"))
(defn capture-exception
[error {:keys [::request ::tags] :as context}]
(let [context (-> (dissoc context ::request ::tags)
(d/without-nils))]
(sentry/withScope
(fn [scope]
(.addEventProcessor ^js scope (fn [event]
(let [node-request (:internal-request request)]
(parse-request event node-request))))
(doseq [[k v] tags]
(.setTag ^js scope (if (keyword? k) (name k) (str k)) (str v)))
(doseq [[k v] context]
(.setContext ^js scope (if (keyword? k) (name k) (str k)) (clj->js v)))
(sentry/captureException error)))))

View file

@ -7,7 +7,7 @@
(ns app.util.shell
"Shell & FS utilities."
(:require
["child_process" :as chp]
["child_process" :as proc]
["fs" :as fs]
["os" :as os]
["path" :as path]
@ -18,51 +18,44 @@
(defn create-tmpdir!
[prefix]
(p/create
(fn [resolve reject]
(fs/mkdtemp (path/join (os/tmpdir) prefix)
(fn [err dir]
(if err
(reject err)
(resolve dir)))))))
(-> (.mkdtemp fs/promises prefix)
(p/then (fn [result]
(path/join (os/tmpdir) result)))))
(defn move!
[origin-path dest-path]
(.rename fs/promises origin-path dest-path))
(defn stat
[path]
(-> (.stat fs/promises path)
(p/then (fn [data]
{:created-at (inst-ms (.-ctime ^js data))
:size (.-size data)}))
(p/catch (constantly nil))))
(defn rmdir!
[path]
(.rm fs/promises path #js {:recursive true}))
(defn write-file!
[fpath content]
(p/create
(fn [resolve reject]
(fs/writeFile fpath content (fn [err]
(if err
(reject err)
(resolve nil)))))))
(.writeFile fs/promises fpath content))
(defn read-file
[fpath]
(p/create
(fn [resolve reject]
(fs/readFile fpath (fn [err content]
(if err
(reject err)
(resolve content)))))))
(.readFile fs/promises fpath))
(defn run-cmd!
[cmd]
(p/create
(fn [resolve reject]
(l/trace :fn :run-cmd :cmd cmd)
(chp/exec cmd #js {:encoding "buffer"}
(fn [error stdout stderr]
;; (l/trace :fn :run-cmd :stdout stdout)
(if error
(reject error)
(resolve stdout)))))))
(defn rmdir!
[path]
(p/create
(fn [resolve reject]
(fs/rmdir path #js {:recursive true}
(fn [err]
(if err
(reject err)
(resolve nil)))))))
(proc/exec cmd #js {:encoding "buffer"}
(fn [error stdout stderr]
;; (l/trace :fn :run-cmd :stdout stdout)
(if error
(reject error)
(resolve stdout)))))))

View file

@ -1,19 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.zipfile
(:require
["jszip" :as jszip]))
(defn create
[]
(new jszip))
(defn add!
[zfile name data]
(.file ^js zfile name data)
zfile)