diff --git a/dev/resources/dev.edn b/dev/resources/dev.edn index 4733fa0..5f009e9 100644 --- a/dev/resources/dev.edn +++ b/dev/resources/dev.edn @@ -2,11 +2,7 @@ {:file-store #var asciinema.component.local-file-store/local-file-store :exp-set #var asciinema.component.mem-expiring-set/mem-expiring-set} :config - {:app - {:middleware - {:functions {:stacktrace #var ring.middleware.stacktrace/wrap-stacktrace} - :applied ^:replace [:not-found :webjars :ring-defaults :route-aliases :ring-logger :stacktrace]}} - :http + {:http {:port 4000} :db {:uri "jdbc:postgresql://localhost:15432/asciinema_development?user=vagrant"} diff --git a/project.clj b/project.clj index 0f74029..a4ba457 100644 --- a/project.clj +++ b/project.clj @@ -4,25 +4,19 @@ :min-lein-version "2.0.0" :dependencies [[org.clojure/clojure "1.8.0"] [com.stuartsierra/component "0.3.1"] - [metosin/ring-http-response "0.8.0"] [clj-time "0.13.0"] [duct "0.8.2"] - [compojure "1.5.1"] - [metosin/compojure-api "1.1.10"] + [yada "1.2.0"] + [aleph "0.4.1"] + [bidi "2.0.12"] [prismatic/schema "1.1.3"] [environ "1.1.0"] [ring "1.5.0"] - [ring/ring-defaults "0.2.1"] - [ring-jetty-component "0.3.1"] - [ring-webjars "0.1.1"] - [ring-logger-timbre "0.7.5"] [clj-bugsnag "0.2.9"] [clj-aws-s3 "0.3.10" :exclusions [joda-time com.fasterxml.jackson.core/jackson-core com.fasterxml.jackson.core/jackson-annotations]] - [aleph "0.4.1"] [pandect "0.6.1"] [com.taoensso/carmine "2.15.1"] [org.slf4j/slf4j-nop "1.7.21"] - [org.webjars/normalize.css "3.0.2"] [duct/hikaricp-component "0.1.0"] [org.postgresql/postgresql "9.4.1211"] [duct/ragtime-component "0.1.4"]] diff --git a/resources/asciinema/system.edn b/resources/asciinema/system.edn index eaebc66..aded10c 100644 --- a/resources/asciinema/system.edn +++ b/resources/asciinema/system.edn @@ -1,6 +1,5 @@ {:components - {:app #var duct.component.handler/handler-component - :http #var asciinema.component.aleph/aleph-server + {:http #var asciinema.component.yada-listener/yada-listener :db #var asciinema.component.db/hikaricp :ragtime #var duct.component.ragtime/ragtime :file-store #var asciinema.component.s3-file-store/s3-file-store @@ -9,50 +8,11 @@ :endpoints {:asciicasts #var asciinema.endpoint.asciicasts/asciicasts-endpoint} :dependencies - {:http [:app] - :app [:asciicasts] + {:http {:app :asciicasts} :ragtime [:db] :asciicasts [:db :file-store :exp-set :executor]} :config - {:app - {:middleware - {:functions - {:hide-errors #var duct.middleware.errors/wrap-hide-errors - :not-found #var duct.middleware.not-found/wrap-not-found - :ring-defaults #var ring.middleware.defaults/wrap-defaults - :route-aliases #var duct.middleware.route-aliases/wrap-route-aliases - :ring-logger #var ring.logger.timbre/wrap-with-logger - :bugsnag #var clj-bugsnag.ring/wrap-bugsnag - :webjars #var ring.middleware.webjars/wrap-webjars} - :applied - [:not-found :webjars :ring-defaults :route-aliases :ring-logger :bugsnag :hide-errors] - :arguments - {:not-found #resource "asciinema/errors/404.html" - :hide-errors #resource "asciinema/errors/500.html" - :bugsnag - {:api-key bugsnag-key - :environment env-name - :version git-sha - :project-ns "asciinema"} - :route-aliases {"/" "/index.html"} - :ring-defaults - {:params {:urlencoded true - :keywordize true - :multipart true - :nested true} - :cookies true - :session {:flash true - :cookie-attrs {:http-only true}} - :security {:anti-forgery true - :xss-protection {:enable? true, :mode :block} - :frame-options :sameorigin - :content-type-options :nosniff} - :static {:resources "asciinema/public"} - :responses {:not-modified-responses true - :absolute-redirects true - :content-types true - :default-charset "utf-8"}}}}} - :http + {:http {:port http-port} :db {:uri db-uri} diff --git a/src/asciinema/boundary/file_store.clj b/src/asciinema/boundary/file_store.clj index c9fad8c..da2d65d 100644 --- a/src/asciinema/boundary/file_store.clj +++ b/src/asciinema/boundary/file_store.clj @@ -5,4 +5,4 @@ (input-stream [this path]) (move-file [this old-path new-path]) (delete-file [this path]) - (serve-file [this path opts])) + (serve-file [this ctx path opts])) diff --git a/src/asciinema/component/aleph.clj b/src/asciinema/component/aleph.clj deleted file mode 100644 index a77bfbc..0000000 --- a/src/asciinema/component/aleph.clj +++ /dev/null @@ -1,17 +0,0 @@ -(ns asciinema.component.aleph - (:require [com.stuartsierra.component :as component] - [aleph.http :refer [start-server]])) - -(defrecord WebServer [port server app] - component/Lifecycle - (start [component] - (let [handler (:handler app) - server (start-server handler {:port port :join? false})] - (assoc component :server server))) - (stop [component] - (when server - (.close server) - component))) - -(defn aleph-server [{:keys [port app]}] - (map->WebServer {:port port :app app})) diff --git a/src/asciinema/component/fixed_thread_executor.clj b/src/asciinema/component/fixed_thread_executor.clj index fb7255a..7981882 100644 --- a/src/asciinema/component/fixed_thread_executor.clj +++ b/src/asciinema/component/fixed_thread_executor.clj @@ -21,7 +21,7 @@ (.execute executor f) result) (catch RejectedExecutionException _ - {:status 503 :headers {"Retry-After" "5"} :body "

503

"}))) + nil))) component/Lifecycle (start [{:keys [threads queue-length] :as component}] diff --git a/src/asciinema/component/local_file_store.clj b/src/asciinema/component/local_file_store.clj index 21823a0..bf82363 100644 --- a/src/asciinema/component/local_file_store.clj +++ b/src/asciinema/component/local_file_store.clj @@ -27,11 +27,12 @@ (let [path (str base-path path)] (io/delete-file path))) - (serve-file [this path {:keys [filename]}] - (let [resp (response/ok (file-store/input-stream this path))] + (serve-file [this ctx path {:keys [filename]}] + (let [path (str base-path path) + response (assoc (:response ctx) :body (io/file path))] (if filename - (response/header resp "Content-Disposition" (str "attachment; filename=" filename)) - resp)))) + (update response :headers assoc "content-disposition" (str "attachment; filename=" filename)) + response)))) (defn local-file-store [{:keys [path]}] (->LocalFileStore path)) diff --git a/src/asciinema/component/s3_file_store.clj b/src/asciinema/component/s3_file_store.clj index 4013f49..478892d 100644 --- a/src/asciinema/component/s3_file_store.clj +++ b/src/asciinema/component/s3_file_store.clj @@ -50,9 +50,12 @@ (let [path (str path-prefix path)] (s3/delete-object cred bucket path))) - (serve-file [this path opts] - (let [path (str path-prefix path)] - (response/found (generate-presigned-url cred bucket path opts))))) + (serve-file [this ctx path opts] + (let [path (str path-prefix path) + url (generate-presigned-url cred bucket path opts)] + (-> (:response ctx) + (assoc :status 302) + (update :headers assoc "location" url))))) (defn s3-file-store [{:keys [cred bucket path-prefix]}] (->S3FileStore cred bucket path-prefix)) diff --git a/src/asciinema/component/yada_listener.clj b/src/asciinema/component/yada_listener.clj new file mode 100644 index 0000000..d2c8810 --- /dev/null +++ b/src/asciinema/component/yada_listener.clj @@ -0,0 +1,21 @@ +(ns asciinema.component.yada-listener + (:require [com.stuartsierra.component :as component] + [yada.yada :as yada])) + +(defrecord YadaListener [port server app] + component/Lifecycle + (start [component] + (if server + component + (let [handler (:routes app) + server (yada/listener handler {:port port})] + (assoc component :server server)))) + (stop [component] + (if server + (do + ((:close server)) + (assoc component :server nil)) + component))) + +(defn yada-listener [{:keys [port app]}] + (map->YadaListener {:port port :app app})) diff --git a/src/asciinema/endpoint/asciicasts.clj b/src/asciinema/endpoint/asciicasts.clj index ae97d96..acd43d7 100644 --- a/src/asciinema/endpoint/asciicasts.clj +++ b/src/asciinema/endpoint/asciicasts.clj @@ -11,16 +11,15 @@ [clojure.java [io :as io] [shell :as shell]] - [clojure.string :as str] - [compojure.api.sweet :refer :all] [environ.core :refer [env]] - [ring.util.http-response :as response] - [schema.core :as s])) + [schema.core :as s] + [yada.yada :as yada])) -(defn exception-handler [^Exception e data request] - (throw e)) +(def Theme (apply s/enum asciicast/themes)) + +(def png-ttl-days 7) -(defn a2png [in-url out-path {:keys [snapshot-at theme scale]}] +(defn- a2png [in-url out-path {:keys [snapshot-at theme scale]}] (let [a2png-bin (:a2png-bin env "a2png/a2png.sh") {:keys [exit] :as result} (shell/sh a2png-bin "-t" theme @@ -31,53 +30,74 @@ (when-not (zero? exit) (throw (ex-info "a2png error" result))))) -(def Num (s/if #(str/includes? % ".") - Double - s/Int)) +(defn- generate-png [file-store exp-set asciicast png-params png-store-path] + (with-tmp-dir [dir "asciinema-png-"] + (let [json-store-path (asciicast/json-store-path asciicast) + json-local-path (str dir "/asciicast.json") + png-local-path (str dir "/asciicast.png") + expires (-> png-ttl-days t/days t/from-now)] + (with-open [in (fstore/input-stream file-store json-store-path)] + (let [out (io/file json-local-path)] + (io/copy in out))) + (a2png json-local-path png-local-path png-params) + (fstore/put-file file-store (io/file png-local-path) png-store-path) + (exp-set/conj! exp-set png-store-path expires)))) -(def Theme (apply s/enum asciicast/themes)) +(defn- service-unavailable-response [ctx] + (-> (:response ctx) + (assoc :status 503) + (update :headers assoc "retry-after" "5"))) -(def png-ttl-days 7) +(defn- async-response [ctx executor f] + (or (executor/execute executor f) + (service-unavailable-response ctx))) -(defn asciicasts-endpoint [{:keys [db file-store exp-set executor]}] - (api - {:exceptions {:handlers {:compojure.api.exception/default exception-handler}}} - (context - "/a" [] - (GET "/:token.json" [] - :path-params [token :- String] - :query-params [{dl :- s/Bool false}] - (if-let [asciicast (adb/get-asciicast-by-token db token)] - (let [path (asciicast/json-store-path asciicast) - filename (str "asciicast-" (:id asciicast) ".json")] - (fstore/serve-file file-store path (when dl {:filename filename}))) - (response/not-found))) +(defn asciicast-json-resource [db file-store] + (yada/resource {:produces "application/json" + :parameters {:path {:token String} + :query {(s/optional-key :dl) s/Bool}} + :properties (fn [ctx] + (if-let [asciicast (adb/get-asciicast-by-token db (-> ctx :parameters :path :token))] + {:exists? true + ::asciicast asciicast} + {:exists? false})) + :response (fn [ctx] + (let [asciicast (-> ctx :properties ::asciicast) + dl (-> ctx :parameters :query :dl) + path (asciicast/json-store-path asciicast) + filename (str "asciicast-" (:id asciicast) ".json")] + (fstore/serve-file file-store ctx path (when dl {:filename filename}))))})) - (GET "/:token.png" [] - :path-params [token :- String] - :query-params [{time :- Num nil} - {theme :- Theme nil} - {scale :- (s/enum "1" "2") nil}] - (if-let [asciicast (adb/get-asciicast-by-token db token)] - (let [user (udb/get-user-by-id db (:user_id asciicast)) - png-params (cond-> (asciicast/png-params asciicast user) - time (assoc :snapshot-at time) - theme (assoc :theme theme) - scale (assoc :scale (Integer/parseInt scale))) - png-store-path (asciicast/png-store-path asciicast png-params)] - (if (exp-set/contains? exp-set png-store-path) - (fstore/serve-file file-store png-store-path {}) - (executor/execute executor (fn [] - (with-tmp-dir [dir "asciinema-png-"] - (let [json-store-path (asciicast/json-store-path asciicast) - json-local-path (str dir "/asciicast.json") - png-local-path (str dir "/asciicast.png") - expires (-> png-ttl-days t/days t/from-now)] - (with-open [in (fstore/input-stream file-store json-store-path)] - (let [out (io/file json-local-path)] - (io/copy in out))) - (a2png json-local-path png-local-path png-params) - (fstore/put-file file-store (io/file png-local-path) png-store-path) - (exp-set/conj! exp-set png-store-path expires))) - (fstore/serve-file file-store png-store-path {}))))) - (response/not-found)))))) +(defn asciicast-png-resource [db file-store exp-set executor] + (yada/resource {:produces "image/png" + :parameters {:path {:token String} + :query {(s/optional-key :time) s/Num + (s/optional-key :theme) Theme + (s/optional-key :scale) (s/enum "1" "2")}} + :properties (fn [ctx] + (if-let [asciicast (adb/get-asciicast-by-token db (-> ctx :parameters :path :token))] + (let [user (udb/get-user-by-id db (:user_id asciicast)) + {:keys [time theme scale]} (-> ctx :parameters :query) + png-params (cond-> (asciicast/png-params asciicast user) + time (assoc :snapshot-at time) + theme (assoc :theme theme) + scale (assoc :scale (Integer/parseInt scale)))] + {:exists? true + :version (asciicast/png-version asciicast png-params) + ::asciicast asciicast + ::png-params png-params}) + {:exists? false})) + :response (fn [ctx] + (let [asciicast (-> ctx :properties ::asciicast) + png-params (-> ctx :properties ::png-params) + png-store-path (asciicast/png-store-path asciicast png-params)] + (if (exp-set/contains? exp-set png-store-path) + (fstore/serve-file file-store ctx png-store-path {}) + (async-response ctx executor (fn [] + (generate-png file-store exp-set asciicast png-params png-store-path) + (fstore/serve-file file-store ctx png-store-path {}))))))})) + +(defn asciicasts-endpoint [{:keys [db file-store exp-set executor]}] + ["" [["/a/" [[[:token ".json"] (asciicast-json-resource db file-store)] + [[:token ".png"] (asciicast-png-resource db file-store exp-set executor)]]] + [true (yada/as-resource nil)]]]) diff --git a/src/asciinema/model/asciicast.clj b/src/asciinema/model/asciicast.clj index bd2bc19..8d28d11 100644 --- a/src/asciinema/model/asciicast.clj +++ b/src/asciinema/model/asciicast.clj @@ -25,7 +25,7 @@ :theme (theme-name asciicast user) :scale default-png-scale}) -(defn- png-version [asciicast params] +(defn png-version [asciicast params] (let [attrs (assoc params :id (:id asciicast))] (->> attrs (map (fn [[k v]] (str (name k) "=" v)))