├── site └── commentator │ ├── .hugo_build.lock │ ├── public │ ├── health.html │ ├── _redirects │ ├── images │ │ ├── favicon.png │ │ ├── gopher-404.jpg │ │ └── clippy.svg │ ├── fonts │ │ ├── Inconsolata.eot │ │ ├── Inconsolata.ttf │ │ ├── Inconsolata.woff │ │ ├── Work_Sans_200.eot │ │ ├── Work_Sans_200.ttf │ │ ├── Work_Sans_200.woff │ │ ├── Work_Sans_200.woff2 │ │ ├── Work_Sans_300.eot │ │ ├── Work_Sans_300.ttf │ │ ├── Work_Sans_300.woff │ │ ├── Work_Sans_300.woff2 │ │ ├── Work_Sans_500.eot │ │ ├── Work_Sans_500.ttf │ │ ├── Work_Sans_500.woff │ │ ├── Work_Sans_500.woff2 │ │ ├── Novecentosanswide-Normal-webfont.eot │ │ ├── Novecentosanswide-Normal-webfont.ttf │ │ ├── Novecentosanswide-Normal-webfont.woff │ │ ├── Novecentosanswide-Normal-webfont.woff2 │ │ ├── Novecentosanswide-UltraLight-webfont.eot │ │ ├── Novecentosanswide-UltraLight-webfont.ttf │ │ ├── Novecentosanswide-UltraLight-webfont.woff │ │ └── Novecentosanswide-UltraLight-webfont.woff2 │ ├── img │ │ ├── mirabelle_streams.png │ │ ├── mirabelle_presentation.png │ │ └── commentator_presentation.jpg │ ├── webfonts │ │ ├── fa-solid-900.eot │ │ ├── fa-solid-900.ttf │ │ ├── fa-brands-400.eot │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff │ │ ├── fa-regular-400.eot │ │ ├── fa-regular-400.ttf │ │ ├── fa-solid-900.woff │ │ ├── fa-solid-900.woff2 │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.woff │ │ └── fa-regular-400.woff2 │ ├── index.xml │ ├── api │ │ ├── index.xml │ │ ├── events │ │ │ └── index.xml │ │ ├── comments │ │ │ └── index.xml │ │ └── monitoring │ │ │ └── index.xml │ ├── tags │ │ └── index.xml │ ├── howto │ │ ├── index.xml │ │ ├── use-it │ │ │ └── index.xml │ │ ├── configuration │ │ │ └── index.xml │ │ └── get-build │ │ │ └── index.xml │ ├── categories │ │ └── index.xml │ ├── support │ │ └── index.xml │ ├── css │ │ ├── tabs.css │ │ ├── auto-complete.css │ │ ├── tags.css │ │ ├── featherlight.min.css │ │ ├── hybrid.css │ │ ├── atom-one-dark-reasonable.css │ │ ├── perfect-scrollbar.min.css │ │ ├── theme-blue.css │ │ ├── theme-red.css │ │ ├── theme-green.css │ │ ├── theme-commentator.css │ │ └── hugo-theme.css │ ├── sitemap.xml │ ├── 404.html │ └── js │ │ ├── hugo-learn.js │ │ ├── search.js │ │ ├── auto-complete.js │ │ ├── modernizr.custom-3.6.0.js │ │ ├── featherlight.min.js │ │ ├── clipboard.min.js │ │ └── jquery.sticky.js │ ├── static │ ├── health.html │ └── img │ │ └── commentator_presentation.jpg │ ├── layouts │ └── partials │ │ ├── logo.html │ │ └── menu-footer.html │ ├── content │ ├── howto │ │ ├── _index.md │ │ ├── get-build │ │ │ └── _index.md │ │ ├── configuration │ │ │ └── _index.md │ │ └── use-it │ │ │ └── _index.md │ ├── support │ │ └── _index.md │ ├── api │ │ ├── monitoring │ │ │ └── _index.md │ │ ├── _index.md │ │ ├── events │ │ │ └── _index.md │ │ └── comments │ │ │ └── _index.md │ └── _index.md │ ├── archetypes │ └── default.md │ └── config.toml ├── doc ├── commentator_schema.jpg └── commentator.service ├── .gitmodules ├── src └── commentator │ ├── spec.clj │ ├── interceptor │ ├── auth.clj │ └── cors.clj │ ├── lock.clj │ ├── cache.clj │ ├── rate_limit.clj │ ├── chain.clj │ ├── config.clj │ ├── store.clj │ ├── challenge.clj │ ├── event.clj │ ├── api.clj │ ├── usage.clj │ ├── core.clj │ ├── handler.clj │ └── comment.clj ├── .gitignore ├── release.sh ├── test └── commentator │ ├── interceptor │ └── cors_test.clj │ ├── mock │ └── s3.clj │ ├── rate_limit_test.clj │ ├── challenge_test.clj │ ├── chain_test.clj │ ├── usage_test.clj │ ├── event_test.clj │ └── handler_test.clj ├── .github └── workflows │ └── test.yml ├── Dockerfile ├── dev ├── resources │ └── config.edn └── user.clj ├── README.md ├── project.clj └── integration └── mcorbin └── page.html /site/commentator/.hugo_build.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/commentator/public/health.html: -------------------------------------------------------------------------------- 1 | It works! 2 | -------------------------------------------------------------------------------- /site/commentator/static/health.html: -------------------------------------------------------------------------------- 1 | It works! 2 | -------------------------------------------------------------------------------- /doc/commentator_schema.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/doc/commentator_schema.jpg -------------------------------------------------------------------------------- /site/commentator/public/_redirects: -------------------------------------------------------------------------------- 1 | https://commentator.mcorbin.fr/* https://www.commentator.mcorbin.fr/:splat 301! -------------------------------------------------------------------------------- /site/commentator/layouts/partials/logo.html: -------------------------------------------------------------------------------- 1 |

4 | -------------------------------------------------------------------------------- /site/commentator/layouts/partials/menu-footer.html: -------------------------------------------------------------------------------- 1 |

Powered by mcorbin

2 | -------------------------------------------------------------------------------- /site/commentator/content/howto/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: How to 3 | weight: 1 4 | chapter: true 5 | --- 6 | 7 | # How to use Commentator 8 | -------------------------------------------------------------------------------- /site/commentator/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/images/favicon.png -------------------------------------------------------------------------------- /site/commentator/archetypes/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ replace .Name "-" " " | title }}" 3 | date: {{ .Date }} 4 | draft: true 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /site/commentator/public/fonts/Inconsolata.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Inconsolata.eot -------------------------------------------------------------------------------- /site/commentator/public/fonts/Inconsolata.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Inconsolata.ttf -------------------------------------------------------------------------------- /site/commentator/public/fonts/Inconsolata.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Inconsolata.woff -------------------------------------------------------------------------------- /site/commentator/public/images/gopher-404.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/images/gopher-404.jpg -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_200.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_200.eot -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_200.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_200.ttf -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_200.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_200.woff -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_200.woff2 -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_300.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_300.eot -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_300.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_300.ttf -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_300.woff -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_300.woff2 -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_500.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_500.eot -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_500.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_500.ttf -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_500.woff -------------------------------------------------------------------------------- /site/commentator/public/fonts/Work_Sans_500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Work_Sans_500.woff2 -------------------------------------------------------------------------------- /site/commentator/public/img/mirabelle_streams.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/img/mirabelle_streams.png -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /site/commentator/public/img/mirabelle_presentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/img/mirabelle_presentation.png -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /site/commentator/public/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /site/commentator/public/img/commentator_presentation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/img/commentator_presentation.jpg -------------------------------------------------------------------------------- /site/commentator/static/img/commentator_presentation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/static/img/commentator_presentation.jpg -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "site/commentator/themes/hugo-theme-learn"] 2 | path = site/commentator/themes/hugo-theme-learn 3 | url = https://github.com/matcornic/hugo-theme-learn.git 4 | -------------------------------------------------------------------------------- /site/commentator/public/fonts/Novecentosanswide-Normal-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Novecentosanswide-Normal-webfont.eot -------------------------------------------------------------------------------- /site/commentator/public/fonts/Novecentosanswide-Normal-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Novecentosanswide-Normal-webfont.ttf -------------------------------------------------------------------------------- /src/commentator/spec.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.spec 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::non-empty-string (s/and string? not-empty)) 5 | (s/def ::keyword keyword?) 6 | -------------------------------------------------------------------------------- /site/commentator/public/fonts/Novecentosanswide-Normal-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Novecentosanswide-Normal-webfont.woff -------------------------------------------------------------------------------- /site/commentator/public/fonts/Novecentosanswide-Normal-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Novecentosanswide-Normal-webfont.woff2 -------------------------------------------------------------------------------- /site/commentator/public/fonts/Novecentosanswide-UltraLight-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Novecentosanswide-UltraLight-webfont.eot -------------------------------------------------------------------------------- /site/commentator/public/fonts/Novecentosanswide-UltraLight-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Novecentosanswide-UltraLight-webfont.ttf -------------------------------------------------------------------------------- /site/commentator/public/fonts/Novecentosanswide-UltraLight-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Novecentosanswide-UltraLight-webfont.woff -------------------------------------------------------------------------------- /site/commentator/public/fonts/Novecentosanswide-UltraLight-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcorbin/commentator/HEAD/site/commentator/public/fonts/Novecentosanswide-UltraLight-webfont.woff2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | profiles.clj 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | .hgignore 12 | .hg/ 13 | .clj-kondo/ 14 | .lsp/ -------------------------------------------------------------------------------- /site/commentator/content/support/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Support and contributions 3 | weight: 30 4 | chapter: false 5 | --- 6 | 7 | 8 | ## Need help ? 9 | 10 | If you need help, please open issues on [Github](https://github.com/mcorbin/commentator). 11 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | tag=$1 3 | lein test 4 | 5 | git add . 6 | git commit -m "release ${tag}" 7 | git tag -a "${tag}" -m "release ${tag}" 8 | docker build -t mcorbin/commentator:${tag} . 9 | docker push mcorbin/commentator:${tag} 10 | git push --tags 11 | git push 12 | -------------------------------------------------------------------------------- /src/commentator/interceptor/auth.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.interceptor.auth 2 | (:require [corbihttp.interceptor.auth :as itc-auth])) 3 | 4 | (defn auth 5 | [username password] 6 | {:name ::auth 7 | :enter (fn [ctx] 8 | (let [admin? (-> ctx :handler :auth)] 9 | (if admin? 10 | (itc-auth/check username password "commentator" ctx) 11 | ctx)))}) 12 | -------------------------------------------------------------------------------- /site/commentator/content/api/monitoring/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Monitoring 3 | weight: 30 4 | disableToc: false 5 | --- 6 | 7 | ## Healthz 8 | 9 | - **GET** `/healthz` 10 | 11 | --- 12 | 13 | ``` 14 | curl localhost:8787/healthz 15 | 16 | {"message":"ok"} 17 | ``` 18 | 19 | ## Metrics 20 | 21 | You can [configure](howto/configuration/) Commentator to expose metrics using the Prometheus format on a dedicated port. 22 | -------------------------------------------------------------------------------- /site/commentator/content/api/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API 3 | weight: 20 4 | chapter: false 5 | --- 6 | 7 | The Commentator API is used to manage comments and events. 8 | 9 | Some API calls (to approve or delete comments for exemple) are only availables for admin users. 10 | 11 | Admin users should provide the token (defined in the [configuration](/howto/configuration/) in the `Authorization` header. 12 | 13 | New ways of authenticating admin users may be added in the future. 14 | -------------------------------------------------------------------------------- /test/commentator/interceptor/cors_test.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.interceptor.cors-test 2 | (:require [clojure.test :refer :all] 3 | [commentator.interceptor.cors :as cors])) 4 | 5 | (deftest get-origin-test 6 | (is (= "foo.com" 7 | (cors/get-origin {:headers {"origin" "foo.com"}} 8 | #{"bar.com" "foo.com"}))) 9 | (is (= "bar.com" 10 | (cors/get-origin {:headers {"origin" "foo.com"}} 11 | #{"bar.com"})))) 12 | -------------------------------------------------------------------------------- /doc/commentator.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Your favorite commenting system 3 | After=network.target 4 | ConditionPathExists=/etc/commentator/commentator.edn 5 | 6 | [Service] 7 | User=commentator 8 | Environment=COMMENTATOR_CONFIGURATION=/etc/commentator/commentator.edn 9 | Environment=ACCESS_KEY=value 10 | Environment=SECRET_KEY=value 11 | Group=commentator 12 | ExecStart=/usr/bin/java -Xms300m -Xmx300m -XX:+ExitOnOutOfMemoryError -jar /opt/commentator.jar 13 | Restart=on-failure 14 | 15 | [Install] 16 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /site/commentator/public/images/clippy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/commentator/public/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Commentator 5 | https://www.commentator.mcorbin.fr/ 6 | Recent content on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/api/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | API on Commentator 5 | https://www.commentator.mcorbin.fr/api/ 6 | Recent content in API on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/tags/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tags on Commentator 5 | https://www.commentator.mcorbin.fr/tags/ 6 | Recent content in Tags on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches : [master] 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - name: checkout 12 | uses: actions/checkout@v2 13 | - name: install java 14 | uses: actions/setup-java@v1 15 | with: 16 | java-version: 1.17 17 | - name: Install leiningen 18 | uses: DeLaGuardo/setup-clojure@3.4 19 | with: 20 | lein: latest 21 | - name: Run lein tests 22 | run: lein test 23 | -------------------------------------------------------------------------------- /site/commentator/public/howto/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | How to on Commentator 5 | https://www.commentator.mcorbin.fr/howto/ 6 | Recent content in How to on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/api/events/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Events on Commentator 5 | https://www.commentator.mcorbin.fr/api/events/ 6 | Recent content in Events on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/categories/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Categories on Commentator 5 | https://www.commentator.mcorbin.fr/categories/ 6 | Recent content in Categories on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/howto/use-it/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Use it on Commentator 5 | https://www.commentator.mcorbin.fr/howto/use-it/ 6 | Recent content in Use it on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/api/comments/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Comments on Commentator 5 | https://www.commentator.mcorbin.fr/api/comments/ 6 | Recent content in Comments on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/api/monitoring/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Monitoring on Commentator 5 | https://www.commentator.mcorbin.fr/api/monitoring/ 6 | Recent content in Monitoring on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/config.toml: -------------------------------------------------------------------------------- 1 | baseURL = "https://www.commentator.mcorbin.fr" 2 | languageCode = "en-us" 3 | title = "Commentator" 4 | theme = "hugo-theme-learn" 5 | 6 | [params] 7 | themeVariant = "commentator" 8 | disableInlineCopyToClipBoard = true 9 | 10 | 11 | [[menu.shortcuts]] 12 | name = " Github repo" 13 | identifier = "ds" 14 | url = "https://github.com/mcorbin/commentator" 15 | weight = 10 16 | 17 | [[menu.shortcuts]] 18 | name = " Site theme" 19 | identifier = "learntheme" 20 | url = "https://github.com/matcornic/hugo-theme-learn/" 21 | weight = 20 -------------------------------------------------------------------------------- /site/commentator/public/support/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Support and contributions on Commentator 5 | https://www.commentator.mcorbin.fr/support/ 6 | Recent content in Support and contributions on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/howto/configuration/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Configuration on Commentator 5 | https://www.commentator.mcorbin.fr/howto/configuration/ 6 | Recent content in Configuration on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /site/commentator/public/howto/get-build/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Get and launch Commentator on Commentator 5 | https://www.commentator.mcorbin.fr/howto/get-build/ 6 | Recent content in Get and launch Commentator on Commentator 7 | Hugo -- gohugo.io 8 | en-us 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/commentator/lock.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.lock 2 | (:require [com.stuartsierra.component :as component])) 3 | 4 | (defprotocol ILock 5 | (get-lock [this lock-name] "Returns a lock by name. Creates one if it does not exist")) 6 | 7 | (defrecord Lock [lock-map] 8 | component/Lifecycle 9 | (start [this] 10 | (assoc this :lock-map (atom {}))) 11 | (stop [this] 12 | (assoc this :lock-map nil)) 13 | ILock 14 | (get-lock [_ lock-name] 15 | (get (swap! lock-map (fn [state] 16 | (if (get state lock-name) 17 | state 18 | (assoc state lock-name (java.lang.Object.))))) 19 | lock-name))) 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM clojure:openjdk-17-lein as build-env 2 | 3 | ADD . /app 4 | WORKDIR /app 5 | 6 | RUN lein uberjar 7 | 8 | # ----------------------------------------------------------------------------- 9 | 10 | FROM eclipse-temurin:17 11 | 12 | RUN groupadd -r commentator && useradd -r -s /bin/false -g commentator commentator 13 | 14 | RUN mkdir /app 15 | COPY --from=build-env --chown=commentator:commentator /app/target/uberjar/commentator-*-standalone.jar /app/commentator.jar 16 | ENV COMMENTATOR_CONFIGURATION=/app/config.edn 17 | USER commentator 18 | 19 | ENTRYPOINT ["java", "-ea", "-XX:+AlwaysPreTouch", "-XX:MaxRAMPercentage=90", "-cp", "/app/commentator.jar"] 20 | 21 | CMD ["commentator.core"] 22 | -------------------------------------------------------------------------------- /test/commentator/mock/s3.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.mock.s3 2 | (:require [commentator.store :as store] 3 | [spy.protocol :as protocol])) 4 | 5 | 6 | (defrecord StoreMock [exists? get-resource save-resource delete-resource] 7 | store/IStoreOperator 8 | (exists? [this website resource-name] 9 | (exists? website resource-name)) 10 | (get-resource [this website resource-name] 11 | (get-resource website resource-name)) 12 | (save-resource [this website resource-name content] 13 | (save-resource resource-name content)) 14 | (delete-resource [this website resource-name] 15 | (delete-resource resource-name))) 16 | 17 | (defn store-mock 18 | "Creates a mock for the store component." 19 | [config] 20 | (protocol/spy store/IStoreOperator 21 | (map->StoreMock config))) 22 | -------------------------------------------------------------------------------- /src/commentator/cache.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.cache 2 | (:require [clojure.core.cache.wrapped :as c] 3 | [com.stuartsierra.component :as component])) 4 | 5 | (defprotocol Cache 6 | (lookup [this website article]) 7 | (miss [this website article comments]) 8 | (evict [this website article])) 9 | 10 | (def cache-ttl (* 1000 60 60 24)) 11 | 12 | (defn cache-key 13 | [website article] 14 | (str website "/" article)) 15 | 16 | (defrecord MemoryCache [cache] 17 | component/Lifecycle 18 | (start [this] 19 | (assoc this :cache (c/ttl-cache-factory {} :ttl cache-ttl))) 20 | (stop [this] 21 | (assoc this :cache nil)) 22 | Cache 23 | (lookup [_ website article] 24 | (c/lookup cache (cache-key website article))) 25 | (miss [_ website article comments] 26 | (c/miss cache (cache-key website article) comments)) 27 | (evict [_ website article] 28 | (c/evict cache (cache-key website article)))) 29 | -------------------------------------------------------------------------------- /site/commentator/public/css/tabs.css: -------------------------------------------------------------------------------- 1 | #body .tab-nav-button { 2 | border-width: 1px 1px 1px 1px !important; 3 | border-color: #ccc !important; 4 | border-radius: 4px 4px 0 0 !important; 5 | background-color: #ddd !important; 6 | float: left; 7 | display: block; 8 | position: relative; 9 | margin-left: 4px; 10 | bottom: -1px; 11 | } 12 | #body .tab-nav-button:first-child { 13 | margin-left: 0px; 14 | } 15 | #body .tab-nav-button.active { 16 | background-color: #fff !important; 17 | border-bottom-color: #fff !important; 18 | } 19 | 20 | #body .tab-panel { 21 | margin-top: 32px; 22 | margin-bottom: 32px; 23 | } 24 | #body .tab-content { 25 | display: block; 26 | clear: both; 27 | padding: 8px; 28 | border-width: 1px; 29 | border-style: solid; 30 | border-color: #ccc; 31 | } 32 | #body .tab-content .tab-item{ 33 | display: none; 34 | } 35 | 36 | #body .tab-content .tab-item.active{ 37 | display: block; 38 | } 39 | 40 | #body .tab-item pre{ 41 | margin-bottom: 0; 42 | margin-top: 0; 43 | } 44 | -------------------------------------------------------------------------------- /dev/resources/config.edn: -------------------------------------------------------------------------------- 1 | {:http {:host "127.0.0.1" 2 | :port 8787} 3 | :allow-origin ["https://www.mcorbin.fr"] 4 | :rate-limit-minutes 5 5 | :admin {:token #secret "my-super-token"} 6 | :store {:access-key #secret #env ACCESS_KEY 7 | :secret-key #secret #env SECRET_KEY 8 | :bucket-prefix "commentator-dev-" 9 | :endpoint "https://sos-ch-gva-2.exo.io"} 10 | :comment {:auto-approve false 11 | :allowed-articles {"mcorbin-fr" ["foo" 12 | "bar"]}} 13 | :logging {:level "info" 14 | :console {:encoder "json"} 15 | :overrides {:org.eclipse.jetty "info" 16 | :org.apache.http "error"}} 17 | :prometheus {:host "127.0.0.1" 18 | :port 8788} 19 | :challenges {:type :questions 20 | :ttl 120 21 | :questions [{:question "1 + 4 = ?" 22 | :answer "5"} 23 | {:question "1 + 9 = ?" 24 | :answer "10"}] 25 | :secret #secret "aezaz"}} 26 | -------------------------------------------------------------------------------- /site/commentator/public/css/auto-complete.css: -------------------------------------------------------------------------------- 1 | .autocomplete-suggestions { 2 | text-align: left; 3 | cursor: default; 4 | border: 1px solid #ccc; 5 | border-top: 0; 6 | background: #fff; 7 | box-shadow: -1px 1px 3px rgba(0,0,0,.1); 8 | 9 | /* core styles should not be changed */ 10 | position: absolute; 11 | display: none; 12 | z-index: 9999; 13 | max-height: 254px; 14 | overflow: hidden; 15 | overflow-y: auto; 16 | box-sizing: border-box; 17 | 18 | } 19 | .autocomplete-suggestion { 20 | position: relative; 21 | cursor: pointer; 22 | padding: 7px; 23 | line-height: 23px; 24 | white-space: nowrap; 25 | overflow: hidden; 26 | text-overflow: ellipsis; 27 | color: #333; 28 | } 29 | 30 | .autocomplete-suggestion b { 31 | font-weight: normal; 32 | color: #1f8dd6; 33 | } 34 | 35 | .autocomplete-suggestion.selected { 36 | background: #333; 37 | color: #fff; 38 | } 39 | 40 | .autocomplete-suggestion:hover { 41 | background: #444; 42 | color: #fff; 43 | } 44 | 45 | .autocomplete-suggestion > .context { 46 | font-size: 12px; 47 | } 48 | -------------------------------------------------------------------------------- /site/commentator/content/howto/get-build/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Get and launch Commentator 3 | weight: 1 4 | disableToc: false 5 | --- 6 | 7 | ## Get Commentator jar file 8 | 9 | You can download the Commentator jar from [Github](https://github.com/mcorbin/commentator/releases). Commentator is tested under Java 17 (LTS). 10 | 11 | You can also build Commentator yourself. You will need to do that: 12 | 13 | - [Leiningen](https://leiningen.org/), the Clojure build tool. 14 | - Java 11. 15 | 16 | Then, clone the [Git repository](https://github.com/mcorbin/commentator). You can now build the project with `lein uberjar`. 17 | 18 | The resulting jar will be in `target/uberjar/commentator--standalone.jar` 19 | 20 | ## Launch Commentator 21 | 22 | Commentator needs a [configuration file](/howto/configuration/). You need to set the environment variable `COMMENTATOR_CONFIGURATION` to the path of this file. 23 | 24 | ### Using Docker 25 | 26 | The Docker image is available on the [Docker Hub](https://hub.docker.com/r/mcorbin/commentator). 27 | You will need to mount the configuration file as a volume (or inject it into the running container somehow) in order to make it work. 28 | -------------------------------------------------------------------------------- /src/commentator/rate_limit.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.rate-limit 2 | "Rate limit for new comments" 3 | (:require [com.stuartsierra.component :as component] 4 | [clojure.core.cache.wrapped :as c] 5 | [exoscale.ex :as ex])) 6 | 7 | (defprotocol IRateLimiter 8 | (validate [this request website] "Verifies if this request is not rate limited")) 9 | 10 | (defn source-ip 11 | [request] 12 | (or (get-in request [:headers "x-forwarded-for"]) 13 | (:remote-addr request))) 14 | 15 | (defrecord SimpleRateLimiter [rate-limit-minutes ttl-cache] 16 | component/Lifecycle 17 | (start [this] 18 | (assoc this :ttl-cache (c/ttl-cache-factory {} :ttl (* 1000 60 rate-limit-minutes)))) 19 | (stop [this] 20 | (assoc this :ttl-cache nil)) 21 | IRateLimiter 22 | (validate [_ request website] 23 | (let [ip (source-ip request) 24 | cache-key (str website "-" ip)] 25 | (if (c/has? ttl-cache cache-key) 26 | (throw (ex/ex-info "You are rate limited, please wait" 27 | [::rate-limited [:corbi/user ::ex/forbidden]] 28 | {})) 29 | (do (c/miss ttl-cache cache-key true) 30 | true))))) 31 | -------------------------------------------------------------------------------- /site/commentator/content/api/events/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Events 3 | weight: 1 4 | disableToc: false 5 | --- 6 | 7 | **Admin API** 8 | 9 | ## List events 10 | 11 | - **GET** `/api/admin/event/` 12 | 13 | --- 14 | 15 | ``` 16 | curl -H "Authorization: my-super-token" http://localhost:8787/api/admin/event/mcorbin-fr 17 | 18 | [ 19 | { 20 | "timestamp": 1628364816474, 21 | "id": "01970a27-eb5f-4ee5-97af-a5a11a427824", 22 | "article": "foo", 23 | "message": "New comment 67f99f31-9724-4288-94d7-4fc860dab744 on article foo", 24 | "comment-id": "67f99f31-9724-4288-94d7-4fc860dab744", 25 | "type": "new-comment" 26 | } 27 | ] 28 | ``` 29 | 30 | ## Delete an event 31 | 32 | - **DELETE** `/api/admin/event/` 33 | 34 | --- 35 | 36 | ``` 37 | curl -H "Authorization: my-super-token" http://localhost:8787/api/admin/event/mcorbin-fr 38 | 39 | [ 40 | { 41 | "timestamp": 1628364816474, 42 | "id": "01970a27-eb5f-4ee5-97af-a5a11a427824", 43 | "article": "foo", 44 | "message": "New comment 67f99f31-9724-4288-94d7-4fc860dab744 on article foo", 45 | "comment-id": "67f99f31-9724-4288-94d7-4fc860dab744", 46 | "type": "new-comment" 47 | } 48 | ] 49 | ``` 50 | -------------------------------------------------------------------------------- /site/commentator/public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | https://www.commentator.mcorbin.fr/api/comments/ 6 | 7 | https://www.commentator.mcorbin.fr/api/events/ 8 | 9 | https://www.commentator.mcorbin.fr/howto/get-build/ 10 | 11 | https://www.commentator.mcorbin.fr/howto/ 12 | 13 | https://www.commentator.mcorbin.fr/howto/configuration/ 14 | 15 | https://www.commentator.mcorbin.fr/api/ 16 | 17 | https://www.commentator.mcorbin.fr/ 18 | 19 | https://www.commentator.mcorbin.fr/api/monitoring/ 20 | 21 | https://www.commentator.mcorbin.fr/support/ 22 | 23 | https://www.commentator.mcorbin.fr/howto/use-it/ 24 | 25 | https://www.commentator.mcorbin.fr/categories/ 26 | 27 | https://www.commentator.mcorbin.fr/tags/ 28 | 29 | 30 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require [clojure.tools.namespace.repl :refer [refresh]] 3 | [commentator.core :as core])) 4 | 5 | (defn start! 6 | [] 7 | (core/start!) 8 | "started") 9 | 10 | (defn stop! 11 | [] 12 | (core/stop!) 13 | "stopped") 14 | 15 | (defn restart! 16 | [] 17 | (stop!) 18 | (refresh) 19 | (start!)) 20 | 21 | (defn challenge 22 | [] 23 | (let [operations [* + -] 24 | mapping {* " * " + " + " - " - "} 25 | n1 (rand-int 12) 26 | n2 (rand-int 12) 27 | op (rand-nth operations) 28 | result (op n1 n2) 29 | ] 30 | () 31 | {:question (format "what is the result of: %d %s %d" 32 | n1 33 | (get mapping op) 34 | n2) 35 | :answer (str result)})) 36 | 37 | (defn challenges 38 | [n] 39 | (->> {:challenges (reduce (fn [state _] (assoc state 40 | (keyword (str (java.util.UUID/randomUUID))) 41 | (challenge))) 42 | {} 43 | (range n))} 44 | pr-str 45 | (spit "/tmp/challenges.edn")) 46 | 47 | ) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Commentator 2 | 3 | A commenting system for your blog or website. Comments are stored on any S3-compatible store. 4 | 5 | I built commentator to provide an easy to use (and easy to host) free commenting system with advanced functionalities. 6 | 7 | ## Features 8 | 9 | Commentator provides: 10 | 11 | - [x] Multi website support: one instance of Commentator can manage comments for multiple websites. 12 | - [x] An easy-to-use API to manage comments and events. A public API allows users to create or retrieve approved comments, and a admin API allows you to administrate comments (approve them or delete them for example). 13 | Everytime a comment is added an event is generated into a dedicated file on S3. The API allows you to read and delete the events. You can use this file to be notified when a new comment is added. 14 | - [x] Rate limiting, either by IP or using the requests `x-forwarded-for` header. 15 | - [x] A "challenge" system to avoid spammers. 16 | - [x] An in-memory cache for comments, for performances and to avoid hitting S3 too much. 17 | - [x] Metrics about the applications exposed using the Prometheus format. 18 | 19 | ## Documentation 20 | 21 | The documentation is available at https://www.commentator.mcorbin.fr/ 22 | 23 | -------------------------------------------------------------------------------- /site/commentator/public/css/tags.css: -------------------------------------------------------------------------------- 1 | /* Tags */ 2 | 3 | #head-tags{ 4 | margin-left:1em; 5 | margin-top:1em; 6 | } 7 | 8 | #body .tags a.tag-link { 9 | display: inline-block; 10 | line-height: 2em; 11 | font-size: 0.8em; 12 | position: relative; 13 | margin: 0 16px 8px 0; 14 | padding: 0 10px 0 12px; 15 | background: #8451a1; 16 | 17 | -webkit-border-bottom-right-radius: 3px; 18 | border-bottom-right-radius: 3px; 19 | -webkit-border-top-right-radius: 3px; 20 | border-top-right-radius: 3px; 21 | 22 | -webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.2); 23 | box-shadow: 0 1px 2px rgba(0,0,0,0.2); 24 | color: #fff; 25 | } 26 | 27 | #body .tags a.tag-link:before { 28 | content: ""; 29 | position: absolute; 30 | top:0; 31 | left: -1em; 32 | width: 0; 33 | height: 0; 34 | border-color: transparent #8451a1 transparent transparent; 35 | border-style: solid; 36 | border-width: 1em 1em 1em 0; 37 | } 38 | 39 | #body .tags a.tag-link:after { 40 | content: ""; 41 | position: absolute; 42 | top: 10px; 43 | left: 1px; 44 | width: 5px; 45 | height: 5px; 46 | -webkit-border-radius: 50%; 47 | border-radius: 100%; 48 | background: #fff; 49 | } 50 | -------------------------------------------------------------------------------- /src/commentator/interceptor/cors.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.interceptor.cors 2 | (:require [exoscale.interceptor :as interceptor])) 3 | 4 | (defn get-origin 5 | "Returns the value to set to the Access-Control-Allow-Origin header, or the 6 | random value from allow-origin if the request Origin is lot allowed" 7 | [request allow-origin] 8 | (or (allow-origin (get-in request [:headers "origin"])) 9 | (first allow-origin))) 10 | 11 | (defn cors 12 | [allow-origin] 13 | {:name ::cors 14 | :enter (fn [ctx] 15 | (if (= :options (get-in ctx [:request :request-method])) 16 | (interceptor/halt {:status 200 17 | :headers {"Access-Control-Allow-Origin" 18 | (get-origin (:request ctx) allow-origin) 19 | "Vary" "Origin" 20 | "Access-Control-Allow-Methods" "POST, GET, OPTIONS" 21 | "Access-Control-Allow-Headers" "Content-Type"}}) 22 | ctx)) 23 | :leave (fn [ctx] 24 | (let [headers (merge (get-in ctx [:response :headers]) 25 | {"Vary" "Origin" 26 | "Access-Control-Allow-Origin" 27 | (get-origin (:request ctx) allow-origin)})] 28 | (assoc-in ctx [:response :headers] headers)))}) 29 | -------------------------------------------------------------------------------- /test/commentator/rate_limit_test.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.rate-limit-test 2 | (:require [clojure.test :refer :all] 3 | [com.stuartsierra.component :as component] 4 | [commentator.rate-limit :as rl])) 5 | 6 | (deftest rate-limiter-test 7 | (let [limiter (component/start (rl/map->SimpleRateLimiter {:rate-limit-minutes 10}))] 8 | (is (rl/validate limiter {:remote-addr "10.1.1.2"} "foo")) 9 | (is (thrown-with-msg? 10 | Exception 11 | #"You are rate limited, please wait" 12 | (rl/validate limiter {:remote-addr "10.1.1.2"} "foo"))) 13 | (is (rl/validate limiter {:remote-addr "10.1.1.2"} "bar")) 14 | (is (thrown-with-msg? 15 | Exception 16 | #"You are rate limited, please wait" 17 | (rl/validate limiter {:remote-addr "10.1.1.2"} "bar"))) 18 | (is (rl/validate limiter 19 | {:remote-addr "10.1.1.2" 20 | :headers {"x-forwarded-for" "10.3.3.2"}} 21 | "foo")) 22 | (is (thrown-with-msg? 23 | Exception 24 | #"You are rate limited, please wait" 25 | (rl/validate limiter 26 | {:remote-addr "10.1.1.2" 27 | :headers {"x-forwarded-for" "10.3.3.2"}} 28 | "foo"))) 29 | (is (thrown-with-msg? 30 | Exception 31 | #"You are rate limited, please wait" 32 | (rl/validate limiter 33 | {:headers {"x-forwarded-for" "10.3.3.2"}} 34 | "foo"))))) 35 | -------------------------------------------------------------------------------- /src/commentator/chain.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.chain 2 | (:require [commentator.api :as api] 3 | [commentator.interceptor.auth :as itc-auth] 4 | [commentator.interceptor.cors :as itc-cors] 5 | [commentator.handler :as handler] 6 | [corbihttp.interceptor.error :as itc-error] 7 | [corbihttp.interceptor.id :as itc-id] 8 | [corbihttp.interceptor.json :as itc-json] 9 | [corbihttp.interceptor.metric :as itc-metric] 10 | [corbihttp.interceptor.ring :as itc-ring] 11 | [corbihttp.interceptor.route :as itc-route] 12 | [corbihttp.interceptor.handler :as itc-handler] 13 | [corbihttp.interceptor.response :as itc-response])) 14 | 15 | (defn interceptor-chain 16 | [{:keys [username password api-handler registry allow-origin]}] 17 | [itc-response/response ;;leave 18 | itc-json/json ;; leave 19 | (itc-error/last-error registry) ;;error 20 | (itc-metric/response-metrics registry) ;; leave 21 | (itc-cors/cors allow-origin) ;; leave 22 | itc-error/error ;; error 23 | (itc-route/route {:router api/router 24 | :registry registry 25 | :handler-component api-handler 26 | :not-found-handler handler/not-found}) ;; enter 27 | (itc-auth/auth username password) 28 | itc-id/request-id ;;enter 29 | itc-ring/cookies ;; enter + leave 30 | itc-ring/params ;; enter 31 | itc-ring/keyword-params ;; enter 32 | itc-json/request-params ;; enter 33 | (itc-handler/main-handler {:registry registry 34 | :handler-component api-handler})]) 35 | -------------------------------------------------------------------------------- /site/commentator/content/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Commentator 3 | weight: 30 4 | chapter: false 5 | --- 6 | 7 | # Commentator, a free commenting system for your blog 8 | 9 | Commentator is a simple application which provide all you need to have a powerful commenting system on your blogs or websites. 10 | 11 | Commentator uses json files (one per article) stored on any S3-compatible storage to store your comment. The rest of the application is completely stateless. 12 | 13 | Using a S3 compatible store as a database provide several advantages: 14 | 15 | - Easy to use: you don't have to setup a SQL database for example. 16 | - Highly available storage. 17 | - You can use any S3 tools (s3cmd for example) to interact directly with your comments if you want to. 18 | 19 | Commentator also provides: 20 | 21 | - Multi site support: one instance of Commentator can manage comments for multiple websites. 22 | - An easy-to-use API to manage comments and events. A public API allows users to create or retrieve approved comments, and a admin API allows you to administrate comments (approve them or delete them for example). 23 | Everytime a comment is added an event is generated into a dedicated file on S3. The API allows you to read and delete the events. You can use this file to be notified when a new comment is added. 24 | - Rate limiting, either by IP or using the requests `x-forwarded-for` header. 25 | - A "challenge" system to avoid spammers. 26 | - An in-memory cache for comments, for performances and to avoid hitting S3 too much. 27 | - Metrics about the applications exposed using the Prometheus format. 28 | 29 | ![Mirabelle](img/commentator_presentation.jpg) 30 | -------------------------------------------------------------------------------- /site/commentator/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 404 Page not found 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 37 | 38 | 39 | 40 | 41 | 42 |
43 |
44 |
45 |
46 |

Error

47 |

48 |

49 |

Woops. Looks like this page doesn't exist ¯\_(ツ)_/¯.

50 |

51 |

Go to homepage

52 |

Page not found!

53 |
54 |
55 | 56 |
57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject commentator "0.18.0" 2 | :description "A Free commenting system" 3 | :url "https://github.com/mcorbin/commentator" 4 | :license {:name "EPL-2.0" 5 | :url "https://www.eclipse.org/legal/epl-2.0/"} 6 | :dependencies [[amazonica "0.3.156" 7 | :exclusions 8 | [com.amazonaws/aws-java-sdk 9 | com.amazonaws/amazon-kinesis-client]] 10 | [com.amazonaws/aws-java-sdk-core "1.12.128"] 11 | [com.amazonaws/aws-java-sdk-s3 "1.12.128"] 12 | [fr.mcorbin/corbihttp "0.30.0"] 13 | [org.clojure/clojure "1.10.3"] 14 | [org.clojure/core.cache "1.0.225"]] 15 | :main ^:skip-aot commentator.core 16 | :target-path "target/%s" 17 | :source-paths ["src"] 18 | :profiles {:dev {:dependencies [[org.clojure/tools.namespace "1.2.0"] 19 | [pjstadig/humane-test-output "0.11.0"] 20 | [tortue/spy "2.9.0"] 21 | [ring/ring-mock "0.4.0"]] 22 | :global-vars {*assert* true} 23 | :env {:commentator-configuration "dev/resources/config.edn"} 24 | :plugins [[lein-environ "1.1.0"] 25 | [lein-cloverage "1.1.1"] 26 | [lein-ancient "0.6.15"] 27 | [lein-cljfmt "0.6.6"]] 28 | :injections [(require 'pjstadig.humane-test-output) 29 | (pjstadig.humane-test-output/activate!)] 30 | :repl-options {:init-ns user} 31 | :source-paths ["dev"]} 32 | :uberjar {:aot :all}} 33 | :test-selectors {:default (fn [x] (not (:integration x))) 34 | :integration :integration}) 35 | -------------------------------------------------------------------------------- /site/commentator/public/css/featherlight.min.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Featherlight - ultra slim jQuery lightbox 3 | * Version 1.7.13 - http://noelboss.github.io/featherlight/ 4 | * 5 | * Copyright 2018, Noël Raoul Bossart (http://www.noelboss.com) 6 | * MIT Licensed. 7 | **/ 8 | html.with-featherlight{overflow:hidden}.featherlight{display:none;position:fixed;top:0;right:0;bottom:0;left:0;z-index:2147483647;text-align:center;white-space:nowrap;cursor:pointer;background:#333;background:rgba(0,0,0,0)}.featherlight:last-of-type{background:rgba(0,0,0,.8)}.featherlight:before{content:'';display:inline-block;height:100%;vertical-align:middle}.featherlight .featherlight-content{position:relative;text-align:left;vertical-align:middle;display:inline-block;overflow:auto;padding:25px 25px 0;border-bottom:25px solid transparent;margin-left:5%;margin-right:5%;max-height:95%;background:#fff;cursor:auto;white-space:normal}.featherlight .featherlight-inner{display:block}.featherlight link.featherlight-inner,.featherlight script.featherlight-inner,.featherlight style.featherlight-inner{display:none}.featherlight .featherlight-close-icon{position:absolute;z-index:9999;top:0;right:0;line-height:25px;width:25px;cursor:pointer;text-align:center;font-family:Arial,sans-serif;background:#fff;background:rgba(255,255,255,.3);color:#000;border:0;padding:0}.featherlight .featherlight-close-icon::-moz-focus-inner{border:0;padding:0}.featherlight .featherlight-image{width:100%}.featherlight-iframe .featherlight-content{border-bottom:0;padding:0;-webkit-overflow-scrolling:touch}.featherlight iframe{border:0}.featherlight *{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}@media only screen and (max-width:1024px){.featherlight .featherlight-content{margin-left:0;margin-right:0;max-height:98%;padding:10px 10px 0;border-bottom:10px solid transparent}}@media print{html.with-featherlight>*>:not(.featherlight){display:none}} -------------------------------------------------------------------------------- /site/commentator/public/css/hybrid.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | vim-hybrid theme by w0ng (https://github.com/w0ng/vim-hybrid) 4 | 5 | */ 6 | 7 | /*background color*/ 8 | .hljs { 9 | display: block; 10 | overflow-x: auto; 11 | padding: 0.5em; 12 | background: #1d1f21; 13 | } 14 | 15 | /*selection color*/ 16 | .hljs::selection, 17 | .hljs span::selection { 18 | background: #373b41; 19 | } 20 | 21 | .hljs::-moz-selection, 22 | .hljs span::-moz-selection { 23 | background: #373b41; 24 | } 25 | 26 | /*foreground color*/ 27 | .hljs { 28 | color: #c5c8c6; 29 | } 30 | 31 | /*color: fg_yellow*/ 32 | .hljs-title, 33 | .hljs-name { 34 | color: #f0c674; 35 | } 36 | 37 | /*color: fg_comment*/ 38 | .hljs-comment, 39 | .hljs-meta, 40 | .hljs-meta .hljs-keyword { 41 | color: #707880; 42 | } 43 | 44 | /*color: fg_red*/ 45 | .hljs-number, 46 | .hljs-symbol, 47 | .hljs-literal, 48 | .hljs-deletion, 49 | .hljs-link { 50 | color: #cc6666 51 | } 52 | 53 | /*color: fg_green*/ 54 | .hljs-string, 55 | .hljs-doctag, 56 | .hljs-addition, 57 | .hljs-regexp, 58 | .hljs-selector-attr, 59 | .hljs-selector-pseudo { 60 | color: #b5bd68; 61 | } 62 | 63 | /*color: fg_purple*/ 64 | .hljs-attribute, 65 | .hljs-code, 66 | .hljs-selector-id { 67 | color: #b294bb; 68 | } 69 | 70 | /*color: fg_blue*/ 71 | .hljs-keyword, 72 | .hljs-selector-tag, 73 | .hljs-bullet, 74 | .hljs-tag { 75 | color: #81a2be; 76 | } 77 | 78 | /*color: fg_aqua*/ 79 | .hljs-subst, 80 | .hljs-variable, 81 | .hljs-template-tag, 82 | .hljs-template-variable { 83 | color: #8abeb7; 84 | } 85 | 86 | /*color: fg_orange*/ 87 | .hljs-type, 88 | .hljs-built_in, 89 | .hljs-builtin-name, 90 | .hljs-quote, 91 | .hljs-section, 92 | .hljs-selector-class { 93 | color: #de935f; 94 | } 95 | 96 | .hljs-emphasis { 97 | font-style: italic; 98 | } 99 | 100 | .hljs-strong { 101 | font-weight: bold; 102 | } 103 | -------------------------------------------------------------------------------- /site/commentator/public/css/atom-one-dark-reasonable.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Atom One Dark With support for ReasonML by Gidi Morris, based off work by Daniel Gamage 4 | 5 | Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax 6 | 7 | */ 8 | .hljs { 9 | display: block; 10 | overflow-x: auto; 11 | padding: 0.5em; 12 | line-height: 1.3em; 13 | color: #abb2bf; 14 | background: #282c34; 15 | border-radius: 5px; 16 | } 17 | .hljs-keyword, .hljs-operator { 18 | color: #F92672; 19 | } 20 | .hljs-pattern-match { 21 | color: #F92672; 22 | } 23 | .hljs-pattern-match .hljs-constructor { 24 | color: #61aeee; 25 | } 26 | .hljs-function { 27 | color: #61aeee; 28 | } 29 | .hljs-function .hljs-params { 30 | color: #A6E22E; 31 | } 32 | .hljs-function .hljs-params .hljs-typing { 33 | color: #FD971F; 34 | } 35 | .hljs-module-access .hljs-module { 36 | color: #7e57c2; 37 | } 38 | .hljs-constructor { 39 | color: #e2b93d; 40 | } 41 | .hljs-constructor .hljs-string { 42 | color: #9CCC65; 43 | } 44 | .hljs-comment, .hljs-quote { 45 | color: #b18eb1; 46 | font-style: italic; 47 | } 48 | .hljs-doctag, .hljs-formula { 49 | color: #c678dd; 50 | } 51 | .hljs-section, .hljs-name, .hljs-selector-tag, .hljs-deletion, .hljs-subst { 52 | color: #e06c75; 53 | } 54 | .hljs-literal { 55 | color: #56b6c2; 56 | } 57 | .hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta-string { 58 | color: #98c379; 59 | } 60 | .hljs-built_in, .hljs-class .hljs-title { 61 | color: #e6c07b; 62 | } 63 | .hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-type, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-number { 64 | color: #d19a66; 65 | } 66 | .hljs-symbol, .hljs-bullet, .hljs-link, .hljs-meta, .hljs-selector-id, .hljs-title { 67 | color: #61aeee; 68 | } 69 | .hljs-emphasis { 70 | font-style: italic; 71 | } 72 | .hljs-strong { 73 | font-weight: bold; 74 | } 75 | .hljs-link { 76 | text-decoration: underline; 77 | } 78 | -------------------------------------------------------------------------------- /test/commentator/challenge_test.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.challenge-test 2 | (:require [clojure.test :refer :all] 3 | [commentator.challenge :as c]) 4 | (:import commentator.challenge.ChallengeManager)) 5 | 6 | (deftest questions-challenges-test 7 | (let [challenges {:type :questions 8 | :ttl 10 9 | :secret "azeaeazazeea" 10 | :questions [{:question "1 + 4 = ?" 11 | :answer "5"}]} 12 | mng (ChallengeManager. challenges) 13 | challenge (c/random-challenge mng "mcorbin-fr" "test")] 14 | (is (= "1 + 4 = ?" (:question challenge))) 15 | (is (string? (:signature challenge))) 16 | (is (pos-int? (:timestamp challenge))) 17 | (is (c/verify mng (assoc challenge :answer "5" :website "mcorbin-fr" :article "test"))) 18 | (is (thrown-with-msg? 19 | Exception 20 | #"Bad challenge response" 21 | (c/verify mng (assoc challenge :answer "10" :website "mcorbin-fr" :article "test")))) 22 | (is (thrown-with-msg? 23 | Exception 24 | #"Bad challenge response" 25 | (c/verify mng (assoc challenge :answer "5" :website "mcorbin-f" :article "test")))) 26 | (is (thrown-with-msg? 27 | Exception 28 | #"Bad challenge response" 29 | (c/verify mng (assoc challenge :answer "5" :website "mcorbin-fr" :article "tes")))) 30 | (is (thrown-with-msg? 31 | Exception 32 | #"The challenge has expired" 33 | (c/verify mng (-> (assoc challenge :answer "5" :website "mcorbin-fr" :article "test") 34 | (update :timestamp - 20000))))))) 35 | 36 | (deftest math-challenges-test 37 | (let [challenges {:type :math 38 | :ttl 10 39 | :secret "azeaeazazeea"} 40 | mng (ChallengeManager. challenges) 41 | challenge (c/random-challenge mng "mcorbin-fr" "test")] 42 | (is (string? (:question challenge))) 43 | (is (.contains ^String (:question challenge) "what is the result of")) 44 | (is (string? (:signature challenge))) 45 | (is (pos-int? (:timestamp challenge))))) 46 | -------------------------------------------------------------------------------- /src/commentator/config.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.config 2 | (:require [aero.core :as aero] 3 | [clojure.java.io :as io] 4 | [clojure.spec.alpha :as s] 5 | [commentator.spec :as spec] 6 | [environ.core :as env] 7 | [exoscale.cloak :as cloak] 8 | [exoscale.ex :as ex])) 9 | 10 | (s/def ::file (fn [path] 11 | (let [file (io/file path)] 12 | (and (.exists file) 13 | (.isFile file))))) 14 | 15 | (s/def ::host ::spec/non-empty-string) 16 | (s/def ::port pos-int?) 17 | (s/def ::allow-origin (s/coll-of ::spec/non-empty-string :min-count 1)) 18 | (s/def ::key ::file) 19 | (s/def ::cert ::file) 20 | (s/def ::cacert ::file) 21 | (s/def ::http (s/keys :req-un [::host ::port] 22 | :opt-un [::key ::cert ::cacert])) 23 | (s/def ::bucket-prefix (s/and ::spec/non-empty-string 24 | #(< (count %) 25) 25 | #(re-matches #"^[a-zA-Z0-9-_]+$" %))) 26 | 27 | (s/def ::username ::spec/non-empty-string) 28 | (s/def ::password ::cloak/secret) 29 | (s/def ::admin (s/keys :req-un [::username ::password])) 30 | 31 | (s/def ::access-key ::cloak/secret) 32 | (s/def ::secret-key ::cloak/secret) 33 | (s/def ::endpoint ::spec/non-empty-string) 34 | 35 | (s/def ::auto-approve boolean?) 36 | 37 | (s/def ::website (s/and ::spec/non-empty-string 38 | #(< (count %) 40) 39 | #(re-matches #"^[a-zA-Z0-9-_]+$" %))) 40 | (s/def ::allowed-articles (s/map-of ::website (s/coll-of ::spec/non-empty-string))) 41 | 42 | (s/def ::comment (s/keys :req-un [::auto-approve] 43 | :opt-un [::allowed-articles])) 44 | 45 | (s/def ::store (s/keys :req-un [::access-key ::secret-key ::endpoint ::bucket-prefix])) 46 | 47 | (s/def ::question ::spec/non-empty-string) 48 | (s/def ::answer ::spec/non-empty-string) 49 | (s/def ::secret ::cloak/secret) 50 | (s/def ::ttl pos-int?) 51 | 52 | (s/def ::question-challenge (s/keys :req-un [::question ::answer])) 53 | (s/def ::questions (s/coll-of ::question-challenge)) 54 | 55 | (defmulti challengemm :type) 56 | (defmethod challengemm :questions [_] (s/keys :req-un [::questions ::ttl ::secret])) 57 | (defmethod challengemm :math [_] (s/keys :req-un [::ttl ::secret])) 58 | 59 | (s/def ::challenges (s/multi-spec challengemm :type)) 60 | (s/def ::rate-limit-minutes pos-int?) 61 | 62 | (s/def ::prometheus ::http) 63 | 64 | (s/def ::config (s/keys :req-un [::http ::admin ::store ::challenges ::comment ::rate-limit-minutes] 65 | :opt-un [::prometheus])) 66 | 67 | (defmethod aero/reader 'secret 68 | [_ _ value] 69 | (cloak/mask value)) 70 | 71 | (defn load-config 72 | [] 73 | (let [config (aero/read-config (env/env :commentator-configuration) {})] 74 | (if (s/valid? ::config config) 75 | config 76 | (throw (ex/ex-info 77 | "Invalid configuration" 78 | [::invalid [:corbi/user ::ex/incorrect]]))))) 79 | -------------------------------------------------------------------------------- /src/commentator/store.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.store 2 | "The store component is responsible for storing resources." 3 | (:require [amazonica.aws.s3 :as s3] 4 | [exoscale.cloak :as cloak]) 5 | (:import java.io.ByteArrayInputStream 6 | org.apache.commons.codec.digest.DigestUtils 7 | org.apache.commons.codec.binary.Base64 8 | org.apache.commons.io.IOUtils)) 9 | 10 | (defprotocol IStoreOperator 11 | (exists? [this website resource-name] "Checks if a retourne exists") 12 | (get-resource [this website resource-name] "Get a retourne by name") 13 | (save-resource [this website resource-name content] "Save a resource") 14 | (delete-resource [this website resource-name] "Delete a resource")) 15 | 16 | (defn exists?-s3 17 | "Checks if a file exists in s3" 18 | [credentials bucket resource-name] 19 | (s3/does-object-exist (cloak/unmask credentials) 20 | bucket 21 | resource-name)) 22 | 23 | (defn get-resource-from-s3 24 | "Get a file from s3." 25 | [credentials bucket resource-name] 26 | (String. 27 | (IOUtils/toByteArray 28 | (:object-content 29 | (s3/get-object (cloak/unmask credentials) 30 | :bucket-name bucket 31 | :key resource-name))))) 32 | 33 | (defn delete-resource-from-s3 34 | "Delete a file from s3" 35 | [credentials bucket resource-name] 36 | (s3/delete-object (cloak/unmask credentials) 37 | :bucket-name bucket 38 | :key resource-name)) 39 | 40 | (defn save-on-s3 41 | "Creates a new S3 file." 42 | [credentials bucket resource-name ^String content] 43 | (let [bytes (.getBytes content "UTF-8") 44 | input-stream (ByteArrayInputStream. bytes) 45 | digest (DigestUtils/md5 bytes)] 46 | (s3/put-object (cloak/unmask credentials) 47 | :bucket-name bucket 48 | :key resource-name 49 | :input-stream input-stream 50 | :metadata {:content-length (alength bytes) 51 | :content-md5 (String. (Base64/encodeBase64 52 | digest))}))) 53 | 54 | (defn bucket-name 55 | [bucket-prefix website] 56 | (str bucket-prefix website)) 57 | 58 | (defrecord S3 [credentials bucket-prefix] 59 | IStoreOperator 60 | (exists? [_ website resource-name] 61 | (exists?-s3 credentials (bucket-name bucket-prefix website) resource-name)) 62 | (get-resource [_ website resource-name] 63 | (get-resource-from-s3 credentials 64 | (bucket-name bucket-prefix website) 65 | resource-name)) 66 | (save-resource [_ website resource-name content] 67 | (save-on-s3 credentials 68 | (bucket-name bucket-prefix website) 69 | resource-name 70 | content)) 71 | (delete-resource [_ website resource-name] 72 | (delete-resource-from-s3 credentials 73 | (bucket-name bucket-prefix website) 74 | resource-name))) 75 | -------------------------------------------------------------------------------- /src/commentator/challenge.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.challenge 2 | (:require [clojure.string :as string] 3 | [constance.comp :as constance] 4 | [exoscale.cloak :as cloak] 5 | [exoscale.ex :as ex]) 6 | (:import org.apache.commons.codec.digest.HmacUtils)) 7 | 8 | (defprotocol IChallengeManager 9 | (random-challenge [this website article] "Returns a challenge") 10 | (verify [this payload] "Verifies a challenge")) 11 | 12 | (defn hmac 13 | [secret ^String content] 14 | (.hmacHex (HmacUtils. org.apache.commons.codec.digest.HmacAlgorithms/HMAC_SHA_256 15 | (cloak/unmask secret)) 16 | content)) 17 | 18 | (defn signed-content 19 | [ts answer website article] 20 | (str ts "-" answer "-" website "-" article)) 21 | 22 | (defn sign 23 | [secret ^String answer ^String website ^String article] 24 | (let [ts (System/currentTimeMillis) 25 | content (signed-content ts (string/lower-case answer) website article)] 26 | {:timestamp ts 27 | :signature (hmac secret content)})) 28 | 29 | (defn verify-challenge 30 | [secret payload ttl-seconds] 31 | (when (> (System/currentTimeMillis) 32 | (+ (:timestamp payload) (* 1000 ttl-seconds))) 33 | (throw (ex/ex-info "The challenge has expired" 34 | [::bad-challenge [:corbi/user ::ex/incorrect]] 35 | {}))) 36 | (let [content (signed-content (:timestamp payload) 37 | (string/lower-case (:answer payload)) 38 | (:website payload) 39 | (:article payload)) 40 | computed-signature (hmac secret content)] 41 | (when-not (constance/constant-string= computed-signature (:signature payload)) 42 | (throw (ex/ex-info "Bad challenge response" 43 | [::bad-challenge [:corbi/user ::ex/incorrect]] 44 | {})))) 45 | true) 46 | 47 | 48 | (defmulti random 49 | (fn [config _ _] (:type config))) 50 | 51 | (defmethod random :questions 52 | [config website article] 53 | (let [challenge (-> config :questions rand-nth) 54 | signature (sign (:secret config) (:answer challenge) website article)] 55 | (assoc signature :question (:question challenge)))) 56 | 57 | (def operations [* + -]) 58 | (def mapping {* " * " + " + " - " - "}) 59 | 60 | (defmethod random :math 61 | [config website article] 62 | (let [n1 (rand-int 12) 63 | n2 (rand-int 12) 64 | op (rand-nth operations) 65 | challenge {:question (format "what is the result of: %d %s %d" 66 | n1 67 | (get mapping op) 68 | n2) 69 | :answer (str (op n1 n2))} 70 | signature (sign (:secret config) (:answer challenge) website article)] 71 | (assoc signature :question (:question challenge)))) 72 | 73 | (defrecord ChallengeManager [config] 74 | IChallengeManager 75 | (random-challenge [_ website article] 76 | (random config website article)) 77 | (verify [_ payload] 78 | (verify-challenge (:secret config) payload (:ttl config)))) 79 | -------------------------------------------------------------------------------- /site/commentator/public/js/hugo-learn.js: -------------------------------------------------------------------------------- 1 | // Get Parameters from some url 2 | var getUrlParameter = function getUrlParameter(sPageURL) { 3 | var url = sPageURL.split('?'); 4 | var obj = {}; 5 | if (url.length == 2) { 6 | var sURLVariables = url[1].split('&'), 7 | sParameterName, 8 | i; 9 | for (i = 0; i < sURLVariables.length; i++) { 10 | sParameterName = sURLVariables[i].split('='); 11 | obj[sParameterName[0]] = sParameterName[1]; 12 | } 13 | } 14 | return obj; 15 | }; 16 | 17 | // Execute actions on images generated from Markdown pages 18 | var images = $("div#body-inner img").not(".inline"); 19 | // Wrap image inside a featherlight (to get a full size view in a popup) 20 | images.wrap(function(){ 21 | var image =$(this); 22 | var o = getUrlParameter(image[0].src); 23 | var f = o['featherlight']; 24 | // IF featherlight is false, do not use feather light 25 | if (f != 'false') { 26 | if (!image.parent("a").length) { 27 | return ""; 28 | } 29 | } 30 | }); 31 | 32 | // Change styles, depending on parameters set to the image 33 | images.each(function(index){ 34 | var image = $(this) 35 | var o = getUrlParameter(image[0].src); 36 | if (typeof o !== "undefined") { 37 | var h = o["height"]; 38 | var w = o["width"]; 39 | var c = o["classes"]; 40 | image.css("width", function() { 41 | if (typeof w !== "undefined") { 42 | return w; 43 | } else { 44 | return "auto"; 45 | } 46 | }); 47 | image.css("height", function() { 48 | if (typeof h !== "undefined") { 49 | return h; 50 | } else { 51 | return "auto"; 52 | } 53 | }); 54 | if (typeof c !== "undefined") { 55 | var classes = c.split(','); 56 | for (i = 0; i < classes.length; i++) { 57 | image.addClass(classes[i]); 58 | } 59 | } 60 | } 61 | }); 62 | 63 | // Stick the top to the top of the screen when scrolling 64 | $(document).ready(function(){ 65 | $("#top-bar").sticky({topSpacing:0, zIndex: 1000}); 66 | }); 67 | 68 | 69 | jQuery(document).ready(function() { 70 | // Add link button for every 71 | var text, clip = new ClipboardJS('.anchor'); 72 | $("h1~h2,h1~h3,h1~h4,h1~h5,h1~h6").append(function(index, html){ 73 | var element = $(this); 74 | var url = encodeURI(document.location.origin + document.location.pathname); 75 | var link = url + "#"+element[0].id; 76 | return " " + 77 | "" + 78 | "" 79 | ; 80 | }); 81 | 82 | $(".anchor").on('mouseleave', function(e) { 83 | $(this).attr('aria-label', null).removeClass('tooltipped tooltipped-s tooltipped-w'); 84 | }); 85 | 86 | clip.on('success', function(e) { 87 | e.clearSelection(); 88 | $(e.trigger).attr('aria-label', 'Link copied to clipboard!').addClass('tooltipped tooltipped-s'); 89 | }); 90 | $('code.language-mermaid').each(function(index, element) { 91 | var content = $(element).html().replace(/&/g, '&'); 92 | $(element).parent().replaceWith('
' + content + '
'); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/commentator/event.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.event 2 | (:require [clojure.spec.alpha :as s] 3 | [cheshire.core :as json] 4 | [corbihttp.log :as log] 5 | [commentator.lock :as lock] 6 | [commentator.store :as store] 7 | [exoscale.coax :as coax] 8 | [exoscale.ex :as ex]) 9 | (:import java.util.UUID)) 10 | 11 | (def event-file-name "events.json") 12 | 13 | (s/def ::type #{:new-comment}) 14 | (s/def ::timestamp pos-int?) 15 | (s/def ::id uuid?) 16 | (s/def ::comment-id uuid?) 17 | 18 | (s/def ::event (s/keys :req-un [::type ::timestamp ::id ::comment-id])) 19 | (s/def ::events (s/coll-of ::event)) 20 | 21 | (defn new-comment 22 | "Generates an event for a new comment" 23 | [website article id] 24 | {:timestamp (System/currentTimeMillis) 25 | :id (UUID/randomUUID) 26 | :article article 27 | :website website 28 | :message (format "New comment %s on article %s" id article) 29 | :comment-id id 30 | :approve-url (format "/api/admin/comment/%s/%s/%s" website article id) 31 | :type :new-comment}) 32 | 33 | (defprotocol IEventManager 34 | (add-event [this website event]) 35 | (list-events [this website]) 36 | (delete-event [this website event-id])) 37 | 38 | (defrecord EventManager [s3 lock] 39 | IEventManager 40 | (add-event [this website event] 41 | (locking (lock/get-lock lock website) 42 | (let [events (list-events this website)] 43 | (store/save-resource s3 44 | website 45 | event-file-name 46 | (-> (conj events event) 47 | json/generate-string)) 48 | (log/info {:event-id (:id event) 49 | :event-type (:type event)} 50 | (format "publish event %s" (:id event))) 51 | true))) 52 | (list-events [_ website] 53 | (if (store/exists? s3 website event-file-name) 54 | (coax/coerce 55 | ::events 56 | (-> (store/get-resource s3 website event-file-name) 57 | (json/parse-string true) 58 | vec)) 59 | [])) 60 | (delete-event [this website event-id] 61 | (locking (lock/get-lock lock website) 62 | (if (store/exists? s3 website event-file-name) 63 | (let [events (list-events this website) 64 | filtered (remove #(= (:id %) event-id) events)] 65 | (when (= (count events) 66 | (count filtered)) 67 | (throw (ex/ex-info (format "Event %s not found" 68 | event-id) 69 | [::not-found [:corbi/user ::ex/not-found]] 70 | {:event-id event-id}))) 71 | (store/save-resource s3 72 | website 73 | event-file-name 74 | (json/generate-string filtered)) 75 | (log/info {:event-id event-id} 76 | (format "Event %s deleted" event-id))) 77 | (throw (ex/ex-info (format "Event %s not found" 78 | event-id) 79 | [::not-found [:corbi/user ::ex/not-found]] 80 | {:event-id event-id})))))) 81 | -------------------------------------------------------------------------------- /site/commentator/content/howto/configuration/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration 3 | weight: 10 4 | disableToc: false 5 | --- 6 | 7 | The Commentator configuration is defined in [EDN](https://github.com/edn-format/edn). 8 | Here is a commented example configuration file: 9 | 10 | ```clojure 11 | {;; The Mirabelle HTTP Server. 12 | ;; The key, cert, and cacert optional parameters can be set to enable TLS (the 13 | ;; parameters are path to the cert files) 14 | :http {:host "127.0.0.1" 15 | :port 8787} 16 | ;; The list of allowed origins for the public API. 17 | :allow-origin ["https://www.mcorbin.fr"] 18 | ;; The admin token for the admin API calls 19 | :admin {:token #secret "my-super-token"} 20 | ;; The number of minutes for an user before being able to publish another comment 21 | ;; on Commentator. 22 | ;; The `x-forwarded-for` header is first used to get the user IP 23 | ;; If the header is not set it fallbacks to the request source IP. 24 | :rate-limit-minutes 5 25 | :store {;; Your s3 access/secret keys 26 | ;; In this example the #env reader is used but you can also 27 | ;; specify the values without using environment variables if you 28 | ;; want to 29 | :access-key #secret #env ACCESS_KEY 30 | :secret-key #secret #env SECRET_KEY 31 | ;; the prefix for the buckets used by Commentator to store comments 32 | ;; and events 33 | :bucket-prefix "commentator-dev-" 34 | ;; The S3 endpoint 35 | :endpoint "https://sos-ch-gva-2.exo.io"} 36 | :comment {;; Set to true if you want to have comments automatically approved once 37 | ;; created 38 | :auto-approve false 39 | ;; A map containing for each website a list of articles which can receive 40 | ;; comments. You can use this map to disable comments for an article 41 | ;; for example. 42 | :allowed-articles {"mcorbin-fr" ["foo" 43 | "bar"]}} 44 | ;; Logging configuration (https://github.com/pyr/unilog) 45 | :logging {:level "info" 46 | :console {:encoder "json"} 47 | :overrides {:org.eclipse.jetty "info" 48 | :org.apache.http "error"}} 49 | ;; The prometheus configuration to expose the metrics. 50 | ;; The key, cert, and cacert optional parameters can be set to enable TLS (the 51 | ;; parameters are path to the cert files) 52 | :prometheus {:host "127.0.0.1" 53 | :port 8788} 54 | ;; Challenges configurations to avoid spammers 55 | ;; See the different modes to configure challenges on https://www.commentator.mcorbin.fr/howto/use-it/ 56 | :challenges {:type :math 57 | :ttl 120 58 | :secret #secret "azizjiuzarhuaizhaiuzr"}} 59 | ``` 60 | 61 | Commentator can be used to store comments for multiple websites. 62 | 63 | The `:allowed-articles` key should contain the list of articles open for comments for each website. The website names and articles should match the ones used in the [API](/api/comments/). You can also check the [use it](/howto/use-it/) section of the documentation for more information about this. 64 | 65 | Each website will have a dedicated bucket to store its comments and events. 66 | 67 | The bucket name will be ``. The bucket prefix should be a string between 1 and 19 characters, and the website a string between 1 and 39 characters. Allowed characters are letters (both uppercase and lowercase), number, `_` and `-`. 68 | -------------------------------------------------------------------------------- /test/commentator/chain_test.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.chain-test 2 | (:require [clojure.test :refer :all] 3 | [commentator.handler :as h] 4 | [commentator.chain :as chain] 5 | [corbihttp.b64 :as b64] 6 | [exoscale.cloak :as cloak] 7 | [exoscale.interceptor :as interceptor])) 8 | 9 | (deftest auth-admin-test 10 | (let [username "toto" 11 | password "abc123" 12 | auth-header (str "Basic " (b64/to-base64 (str username ":" password))) 13 | chain (chain/interceptor-chain {:username username 14 | :password (cloak/mask password) 15 | :allow-origin #{"foo.com"} 16 | :api-handler 17 | (reify h/IHandler 18 | (new-comment [this request] {:status 200}) 19 | (get-comment [this request] {:status 200}) 20 | (comments-for-article [this request] {:status 200}) 21 | (admin-for-article [this request] {:status 200}) 22 | (delete-comment [this request] {:status 200}) 23 | (delete-article-comments [this request] {:status 200}) 24 | (approve-comment [this request] {:status 200}) 25 | (random-challenge [this request] {:status 200}) 26 | (list-events [this request] {:status 200}) 27 | (delete-event [this request] {:status 200}) 28 | (healthz [this request] {:status 200}) 29 | (metrics [this request] {:status 200}) 30 | (not-found [this request] {:status 404}))}) 31 | handler (fn [request] (interceptor/execute {:request request} chain)) 32 | resp-403 {:status 401 33 | :headers {"WWW-Authenticate" "Basic realm=\"commentator\""} 34 | :exoscale.interceptor/queue nil 35 | :exoscale.interceptor/stack nil}] 36 | (is (= resp-403 37 | (handler {:uri "/api/admin/comment/mcorbin/foo" 38 | :request-method :get 39 | :headers {"authorization" "invalid-token"}}))) 40 | (is (= resp-403 41 | (handler {:uri "/api/admin/comment/mcorbin/foo/aaa" 42 | :request-method :get 43 | :headers {"authorization" "foo:bar"}}))) 44 | (is (= resp-403 45 | (handler {:uri "/api/admin/comment/mcorbin/foo/aaa" 46 | :request-method :post 47 | :headers {"authorization" (str "Basic " (b64/to-base64 (str username ":AA")))}}))) 48 | (is (= resp-403 49 | (handler {:uri "/api/admin/comment/mcorbin/foo" 50 | :request-method :delete 51 | :headers {"authorization" (str "Basic " (b64/to-base64 (str "abc:" password)))}}))) 52 | (is (= resp-403 53 | (handler {:uri "/api/admin/comment/mcorbin/foo/aaa" 54 | :request-method :delete 55 | :headers {"authorization" "invalid-token"}}))) 56 | (is (= resp-403 57 | (handler {:uri "/api/admin/event/mcorbin" 58 | :request-method :get 59 | :headers {"authorization" "invalid-token"}}))) 60 | (is (= resp-403 61 | (handler {:uri "/api/admin/event/mcorbin/foo" 62 | :request-method :delete 63 | :headers {"authorization" "invalid-token"}}))))) 64 | -------------------------------------------------------------------------------- /site/commentator/public/js/search.js: -------------------------------------------------------------------------------- 1 | var lunrIndex, pagesIndex; 2 | 3 | function endsWith(str, suffix) { 4 | return str.indexOf(suffix, str.length - suffix.length) !== -1; 5 | } 6 | 7 | // Initialize lunrjs using our generated index file 8 | function initLunr() { 9 | if (!endsWith(baseurl,"/")){ 10 | baseurl = baseurl+'/' 11 | }; 12 | 13 | // First retrieve the index file 14 | $.getJSON(baseurl +"index.json") 15 | .done(function(index) { 16 | pagesIndex = index; 17 | // Set up lunrjs by declaring the fields we use 18 | // Also provide their boost level for the ranking 19 | lunrIndex = lunr(function() { 20 | this.ref("uri"); 21 | this.field('title', { 22 | boost: 15 23 | }); 24 | this.field('tags', { 25 | boost: 10 26 | }); 27 | this.field("content", { 28 | boost: 5 29 | }); 30 | 31 | this.pipeline.remove(lunr.stemmer); 32 | this.searchPipeline.remove(lunr.stemmer); 33 | 34 | // Feed lunr with each file and let lunr actually index them 35 | pagesIndex.forEach(function(page) { 36 | this.add(page); 37 | }, this); 38 | }) 39 | }) 40 | .fail(function(jqxhr, textStatus, error) { 41 | var err = textStatus + ", " + error; 42 | console.error("Error getting Hugo index file:", err); 43 | }); 44 | } 45 | 46 | /** 47 | * Trigger a search in lunr and transform the result 48 | * 49 | * @param {String} query 50 | * @return {Array} results 51 | */ 52 | function search(queryTerm) { 53 | // Find the item in our index corresponding to the lunr one to have more info 54 | return lunrIndex.search(queryTerm+"^100"+" "+queryTerm+"*^10"+" "+"*"+queryTerm+"^10"+" "+queryTerm+"~2^1").map(function(result) { 55 | return pagesIndex.filter(function(page) { 56 | return page.uri === result.ref; 57 | })[0]; 58 | }); 59 | } 60 | 61 | // Let's get started 62 | initLunr(); 63 | $( document ).ready(function() { 64 | var searchList = new autoComplete({ 65 | /* selector for the search box element */ 66 | selector: $("#search-by").get(0), 67 | /* source is the callback to perform the search */ 68 | source: function(term, response) { 69 | response(search(term)); 70 | }, 71 | /* renderItem displays individual search results */ 72 | renderItem: function(item, term) { 73 | var numContextWords = 2; 74 | var text = item.content.match( 75 | "(?:\\s?(?:[\\w]+)\\s?){0,"+numContextWords+"}" + 76 | term+"(?:\\s?(?:[\\w]+)\\s?){0,"+numContextWords+"}"); 77 | item.context = text; 78 | var divcontext = document.createElement("div"); 79 | divcontext.className = "context"; 80 | divcontext.innerText = (item.context || ''); 81 | var divsuggestion = document.createElement("div"); 82 | divsuggestion.className = "autocomplete-suggestion"; 83 | divsuggestion.setAttribute("data-term", term); 84 | divsuggestion.setAttribute("data-title", item.title); 85 | divsuggestion.setAttribute("data-uri", item.uri); 86 | divsuggestion.setAttribute("data-context", item.context); 87 | divsuggestion.innerText = '» ' + item.title; 88 | divsuggestion.appendChild(divcontext); 89 | return divsuggestion.outerHTML; 90 | }, 91 | /* onSelect callback fires when a search suggestion is chosen */ 92 | onSelect: function(e, term, item) { 93 | location.href = item.getAttribute('data-uri'); 94 | } 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /site/commentator/content/api/comments/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Comments 3 | weight: 1 4 | disableToc: false 5 | --- 6 | 7 | # Public API 8 | 9 | ## Add a comment 10 | 11 | - **POST** `/api/v1/comment//
` 12 | 13 | 14 | | Field | Type | Description | 15 | | ------ | ----------- | ----------- | 16 | | author | string | The author's name | 17 | | author-website | string | The author's website (optional) | 18 | | content | string | The comment content | 19 | | signature | string | The challenge signature | 20 | | timestamp | string | The challenge timestamp | 21 | | answer | string | The challenge answer | 22 | 23 | --- 24 | 25 | ``` 26 | curl -X POST --header "Content-Type: application/json" --data \ '{"author":"mcorbin", "author-website": "mcorbin.fr", "content":"My comment","challenge":"c1","answer":"5", "signature": "", "timestamp": 1639688441946}' http://localhost:8787/api/v1/comment/mcorbin-fr/foo 27 | 28 | {"message":"Comment added"} 29 | ``` 30 | 31 | ## List comments for an article 32 | 33 | - **GET** `/api/v1/comment//
` 34 | 35 | --- 36 | 37 | ``` 38 | curl http://localhost:8787/api/v1/comment/mcorbin-fr/foo 39 | 40 | [ 41 | { 42 | "content": "My comment", 43 | "author": "mcorbin", 44 | "author-website": "mcorbin.fr", 45 | "id": "0bfe788b-3a06-47fe-8a75-4a3ec6a3b8d9", 46 | "approved": true, 47 | "timestamp": 1629101319712 48 | } 49 | ] 50 | 51 | ``` 52 | 53 | ## Get a random challenge 54 | 55 | - **GET** `/api/v1/challenge/` 56 | 57 | --- 58 | 59 | ``` 60 | curl http://localhost:8787/api/v1/challenge/mcorbin-fr/foo 61 | {"timestamp": 1629101319712,"question":"1 + 9 = ?", "signature": ""} 62 | ``` 63 | 64 | # Admin API 65 | 66 | ## Get a comment 67 | 68 | - **GET** `/api/admin/comment//
/` 69 | 70 | --- 71 | 72 | ``` 73 | curl -H "Authorization: my-super-token" http://localhost:8787/api/admin/comment/mcorbin-fr/foo/0bfe788b-3a06-47fe-8a75-4a3ec6a3b8d9 74 | 75 | { 76 | "content": "My comment", 77 | "author": "mcorbin", 78 | "author-website": "mcorbin.fr", 79 | "id": "0bfe788b-3a06-47fe-8a75-4a3ec6a3b8d9", 80 | "approved": false, 81 | "timestamp": 1629101319712 82 | } 83 | ``` 84 | 85 | ## List comments for an article 86 | 87 | - **GET** `/api/admin/comment//
` 88 | 89 | --- 90 | 91 | ``` 92 | curl -H "Authorization: my-super-token" http://localhost:8787/api/admin/comment/mcorbin-fr/foo 93 | [ 94 | { 95 | "content": "My comment", 96 | "author": "mcorbin", 97 | "author-website": "mcorbin.fr", 98 | "id": "0bfe788b-3a06-47fe-8a75-4a3ec6a3b8d9", 99 | "approved": false, 100 | "timestamp": 1629101319712 101 | } 102 | ] 103 | ``` 104 | 105 | ## Approve a comment 106 | 107 | - **POST** `/api/admin/comment//
/` 108 | 109 | --- 110 | 111 | ``` 112 | curl -X POST -H "Authorization: my-super-token" http://localhost:8787/api/admin/comment/mcorbin-fr/foo/0bfe788b-3a06-47fe-8a75-4a3ec6a3b8d9 113 | 114 | { 115 | "message": "Comment approved" 116 | } 117 | ``` 118 | 119 | ## Delete a comment 120 | 121 | - **DELETE** `/api/admin/comment//
/` 122 | 123 | --- 124 | 125 | ``` 126 | curl -X DELETE -H "Authorization: my-super-token" http://localhost:8787/api/admin/comment/mcorbin-fr/foo/0bfe788b-3a06-47fe-8a75-4a3ec6a3b8d9 127 | 128 | { 129 | "message":"Comment deleted" 130 | } 131 | ``` 132 | 133 | ## Delete all comments for an article 134 | 135 | - **DELETE** `/api/admin/comment//
` 136 | 137 | --- 138 | 139 | ``` 140 | curl -X DELETE -H "Authorization: my-super-token" http://localhost:8787/api/admin/comment/mcorbin-fr/foo 141 | 142 | { 143 | "message":"Comments deleted" 144 | } 145 | ``` 146 | -------------------------------------------------------------------------------- /integration/mcorbin/page.html: -------------------------------------------------------------------------------- 1 | - 2 | 62 | 63 |
64 |
65 |
66 | 67 |
68 | 69 | 72 |

Add a comment

73 |

74 | 75 | 76 |

77 | 78 |
79 |

80 |

If you have a bug/issue with the commenting system, please send me an email (my email is in the "About" section).

81 | 82 |
83 | 84 |
85 |
86 | 87 |
88 | {% else %} 89 |
90 |

Comments are disabled for this article

91 |
92 | -------------------------------------------------------------------------------- /site/commentator/public/css/perfect-scrollbar.min.css: -------------------------------------------------------------------------------- 1 | /* perfect-scrollbar v0.6.13 */ 2 | .ps-container{-ms-touch-action:auto;touch-action:auto;overflow:hidden !important;-ms-overflow-style:none}@supports (-ms-overflow-style: none){.ps-container{overflow:auto !important}}@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none){.ps-container{overflow:auto !important}}.ps-container.ps-active-x>.ps-scrollbar-x-rail,.ps-container.ps-active-y>.ps-scrollbar-y-rail{display:block;background-color:transparent}.ps-container.ps-in-scrolling.ps-x>.ps-scrollbar-x-rail{background-color:#eee;opacity:.9}.ps-container.ps-in-scrolling.ps-x>.ps-scrollbar-x-rail>.ps-scrollbar-x{background-color:#999;height:11px}.ps-container.ps-in-scrolling.ps-y>.ps-scrollbar-y-rail{background-color:#eee;opacity:.9}.ps-container.ps-in-scrolling.ps-y>.ps-scrollbar-y-rail>.ps-scrollbar-y{background-color:#999;width:11px}.ps-container>.ps-scrollbar-x-rail{display:none;position:absolute;opacity:0;-webkit-transition:background-color .2s linear, opacity .2s linear;-o-transition:background-color .2s linear, opacity .2s linear;-moz-transition:background-color .2s linear, opacity .2s linear;transition:background-color .2s linear, opacity .2s linear;bottom:0px;height:15px}.ps-container>.ps-scrollbar-x-rail>.ps-scrollbar-x{position:absolute;background-color:#aaa;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, -webkit-border-radius .2s ease-in-out;transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, -webkit-border-radius .2s ease-in-out;-o-transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, border-radius .2s ease-in-out;-moz-transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, border-radius .2s ease-in-out, -moz-border-radius .2s ease-in-out;transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, border-radius .2s ease-in-out;transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, border-radius .2s ease-in-out, -webkit-border-radius .2s ease-in-out, -moz-border-radius .2s ease-in-out;bottom:2px;height:6px}.ps-container>.ps-scrollbar-x-rail:hover>.ps-scrollbar-x,.ps-container>.ps-scrollbar-x-rail:active>.ps-scrollbar-x{height:11px}.ps-container>.ps-scrollbar-y-rail{display:none;position:absolute;opacity:0;-webkit-transition:background-color .2s linear, opacity .2s linear;-o-transition:background-color .2s linear, opacity .2s linear;-moz-transition:background-color .2s linear, opacity .2s linear;transition:background-color .2s linear, opacity .2s linear;right:0;width:15px}.ps-container>.ps-scrollbar-y-rail>.ps-scrollbar-y{position:absolute;background-color:#aaa;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, -webkit-border-radius .2s ease-in-out;transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, -webkit-border-radius .2s ease-in-out;-o-transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, border-radius .2s ease-in-out;-moz-transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, border-radius .2s ease-in-out, -moz-border-radius .2s ease-in-out;transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, border-radius .2s ease-in-out;transition:background-color .2s linear, height .2s linear, width .2s ease-in-out, border-radius .2s ease-in-out, -webkit-border-radius .2s ease-in-out, -moz-border-radius .2s ease-in-out;right:2px;width:6px}.ps-container>.ps-scrollbar-y-rail:hover>.ps-scrollbar-y,.ps-container>.ps-scrollbar-y-rail:active>.ps-scrollbar-y{width:11px}.ps-container:hover.ps-in-scrolling.ps-x>.ps-scrollbar-x-rail{background-color:#eee;opacity:.9}.ps-container:hover.ps-in-scrolling.ps-x>.ps-scrollbar-x-rail>.ps-scrollbar-x{background-color:#999;height:11px}.ps-container:hover.ps-in-scrolling.ps-y>.ps-scrollbar-y-rail{background-color:#eee;opacity:.9}.ps-container:hover.ps-in-scrolling.ps-y>.ps-scrollbar-y-rail>.ps-scrollbar-y{background-color:#999;width:11px}.ps-container:hover>.ps-scrollbar-x-rail,.ps-container:hover>.ps-scrollbar-y-rail{opacity:.6}.ps-container:hover>.ps-scrollbar-x-rail:hover{background-color:#eee;opacity:.9}.ps-container:hover>.ps-scrollbar-x-rail:hover>.ps-scrollbar-x{background-color:#999}.ps-container:hover>.ps-scrollbar-y-rail:hover{background-color:#eee;opacity:.9}.ps-container:hover>.ps-scrollbar-y-rail:hover>.ps-scrollbar-y{background-color:#999} 3 | -------------------------------------------------------------------------------- /test/commentator/usage_test.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.usage-test 2 | (:require [cheshire.core :as json] 3 | [clojure.test :refer :all] 4 | [commentator.mock.s3 :as ms] 5 | [commentator.usage :as usage] 6 | [spy.assert :as assert] 7 | [spy.protocol :as protocol])) 8 | 9 | (deftest purge-cache-test 10 | (is (= {} 11 | (usage/purge-cache {}))) 12 | (is (= {"mcorbin-fr" {}} 13 | (usage/purge-cache {"mcorbin-fr" {"20210201" {"/foo" {"10.0.0.1" 1}}}}))) 14 | (is (= {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1}}}} 15 | (usage/purge-cache {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1}}}}))) 16 | (is (= {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1}}} 17 | "foo.localhost" {(usage/now) {"/bar" {"10.0.0.2" 3}}}} 18 | (usage/purge-cache {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1}} 19 | "20210201" {"/foo" {"10.0.0.1" 1}}} 20 | "foo.localhost" {(usage/now) {"/bar" {"10.0.0.2" 3}} 21 | "20210201" {"/foo" {"10.0.0.1" 1}}}})))) 22 | 23 | (deftest add-request-test 24 | (is (= {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1}}}} 25 | (usage/add-request 26 | {} 27 | {:uri "/bar" 28 | :all-params {:website "mcorbin-fr" :path "/foo"} 29 | :remote-addr "10.0.0.1"}))) 30 | (is (= {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 2}}}} 31 | (usage/add-request 32 | {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1}}}} 33 | {:uri "/foo" 34 | :all-params {:website "mcorbin-fr" :path "/foo"} 35 | :remote-addr "10.0.0.1"}))) 36 | (is (= {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1 37 | "10.0.0.2" 1}}}} 38 | (usage/add-request 39 | {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1}}}} 40 | {:uri "/foo" 41 | :all-params {:website "mcorbin-fr" :path "/foo"} 42 | :remote-addr "10.0.0.2"}))) 43 | (is (= {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1 44 | "10.0.0.2" 2}}}} 45 | (usage/add-request 46 | {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1 47 | "10.0.0.2" 1}}}} 48 | {:uri "/foo" 49 | :all-params {:website "mcorbin-fr" :path "/foo"} 50 | :remote-addr "10.0.0.2"}))) 51 | (is (= {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1 52 | "10.0.0.2" 1} 53 | "/bar" {"10.0.0.3" 1}}}} 54 | (usage/add-request 55 | {"mcorbin-fr" {(usage/now) {"/foo" {"10.0.0.1" 1 56 | "10.0.0.2" 1}}}} 57 | {:uri "/foo" 58 | :all-params {:website "mcorbin-fr" :path "/bar"} 59 | :remote-addr "10.0.0.3"})))) 60 | 61 | (deftest sync-cache-test 62 | (let [store (ms/store-mock {:save-resource (constantly true)}) 63 | now (usage/now) 64 | cache (atom {"mcorbin-fr" {now {"/foo" {"10.0.0.1" 1 65 | "10.0.0.2" 1}}}})] 66 | (usage/sync-cache cache store) 67 | (assert/called-with? (:save-resource (protocol/spies store)) 68 | store 69 | "mcorbin-fr" 70 | (usage/resource-name now) 71 | (json/generate-string {"/foo" {"10.0.0.1" 1 72 | "10.0.0.2" 1}})))) 73 | (deftest compute-usage-for-day-test 74 | (is (= {:pages {"/foo" {:unique 3 75 | :total 20}} 76 | :unique 3 77 | :total 20} 78 | (usage/compute-usage-for-day {"/foo" {"10.0.0.1" 3 79 | "10.2.2.3" 15 80 | "10.2.2.4" 2}}))) 81 | (is (= {:pages {"/foo" {:unique 3 82 | :total 20} 83 | "/bar" {:unique 1 84 | :total 2} 85 | "/foo/bar" {:unique 2 86 | :total 4}} 87 | :unique 6 88 | :total 26} 89 | (usage/compute-usage-for-day {"/foo" {"10.0.0.1" 3 90 | "10.2.2.3" 15 91 | "10.2.2.4" 2} 92 | "/bar" {"10.0.0.1" 2} 93 | "/foo/bar" {"10.2.3.10" 1 94 | "10.0.0.1" 3}})))) 95 | -------------------------------------------------------------------------------- /site/commentator/public/css/theme-blue.css: -------------------------------------------------------------------------------- 1 | 2 | :root{ 3 | 4 | --MAIN-TEXT-color:#323232; /* Color of text by default */ 5 | --MAIN-TITLES-TEXT-color: #5e5e5e; /* Color of titles h2-h3-h4-h5 */ 6 | --MAIN-LINK-color:#1C90F3; /* Color of links */ 7 | --MAIN-LINK-HOVER-color:#167ad0; /* Color of hovered links */ 8 | --MAIN-ANCHOR-color: #1C90F3; /* color of anchors on titles */ 9 | 10 | --MENU-HOME-LINK-color: #323232; /* Color of the home button text */ 11 | --MENU-HOME-LINK-HOVER-color: #5e5e5e; /* Color of the hovered home button text */ 12 | 13 | --MENU-HEADER-BG-color:#1C90F3; /* Background color of menu header */ 14 | --MENU-HEADER-BORDER-color:#33a1ff; /*Color of menu header border */ 15 | 16 | --MENU-SEARCH-BG-color:#167ad0; /* Search field background color (by default borders + icons) */ 17 | --MENU-SEARCH-BOX-color: #33a1ff; /* Override search field border color */ 18 | --MENU-SEARCH-BOX-ICONS-color: #a1d2fd; /* Override search field icons color */ 19 | 20 | --MENU-SECTIONS-ACTIVE-BG-color:#20272b; /* Background color of the active section and its childs */ 21 | --MENU-SECTIONS-BG-color:#252c31; /* Background color of other sections */ 22 | --MENU-SECTIONS-LINK-color: #ccc; /* Color of links in menu */ 23 | --MENU-SECTIONS-LINK-HOVER-color: #e6e6e6; /* Color of links in menu, when hovered */ 24 | --MENU-SECTION-ACTIVE-CATEGORY-color: #777; /* Color of active category text */ 25 | --MENU-SECTION-ACTIVE-CATEGORY-BG-color: #fff; /* Color of background for the active category (only) */ 26 | 27 | --MENU-VISITED-color: #33a1ff; /* Color of 'page visited' icons in menu */ 28 | --MENU-SECTION-HR-color: #20272b; /* Color of
separator in menu */ 29 | 30 | } 31 | 32 | body { 33 | color: var(--MAIN-TEXT-color) !important; 34 | } 35 | 36 | textarea:focus, input[type="email"]:focus, input[type="number"]:focus, input[type="password"]:focus, input[type="search"]:focus, input[type="tel"]:focus, input[type="text"]:focus, input[type="url"]:focus, input[type="color"]:focus, input[type="date"]:focus, input[type="datetime"]:focus, input[type="datetime-local"]:focus, input[type="month"]:focus, input[type="time"]:focus, input[type="week"]:focus, select[multiple=multiple]:focus { 37 | border-color: none; 38 | box-shadow: none; 39 | } 40 | 41 | h2, h3, h4, h5 { 42 | color: var(--MAIN-TITLES-TEXT-color) !important; 43 | } 44 | 45 | a { 46 | color: var(--MAIN-LINK-color); 47 | } 48 | 49 | .anchor { 50 | color: var(--MAIN-ANCHOR-color); 51 | } 52 | 53 | a:hover { 54 | color: var(--MAIN-LINK-HOVER-color); 55 | } 56 | 57 | #sidebar ul li.visited > a .read-icon { 58 | color: var(--MENU-VISITED-color); 59 | } 60 | 61 | #body a.highlight:after { 62 | display: block; 63 | content: ""; 64 | height: 1px; 65 | width: 0%; 66 | -webkit-transition: width 0.5s ease; 67 | -moz-transition: width 0.5s ease; 68 | -ms-transition: width 0.5s ease; 69 | transition: width 0.5s ease; 70 | background-color: var(--MAIN-LINK-HOVER-color); 71 | } 72 | #sidebar { 73 | background-color: var(--MENU-SECTIONS-BG-color); 74 | } 75 | #sidebar #header-wrapper { 76 | background: var(--MENU-HEADER-BG-color); 77 | color: var(--MENU-SEARCH-BOX-color); 78 | border-color: var(--MENU-HEADER-BORDER-color); 79 | } 80 | #sidebar .searchbox { 81 | border-color: var(--MENU-SEARCH-BOX-color); 82 | background: var(--MENU-SEARCH-BG-color); 83 | } 84 | #sidebar ul.topics > li.parent, #sidebar ul.topics > li.active { 85 | background: var(--MENU-SECTIONS-ACTIVE-BG-color); 86 | } 87 | #sidebar .searchbox * { 88 | color: var(--MENU-SEARCH-BOX-ICONS-color); 89 | } 90 | 91 | #sidebar a { 92 | color: var(--MENU-SECTIONS-LINK-color); 93 | } 94 | 95 | #sidebar a:hover { 96 | color: var(--MENU-SECTIONS-LINK-HOVER-color); 97 | } 98 | 99 | #sidebar ul li.active > a { 100 | background: var(--MENU-SECTION-ACTIVE-CATEGORY-BG-color); 101 | color: var(--MENU-SECTION-ACTIVE-CATEGORY-color) !important; 102 | } 103 | 104 | #sidebar hr { 105 | border-color: var(--MENU-SECTION-HR-color); 106 | } 107 | 108 | #body .tags a.tag-link { 109 | background-color: var(--MENU-HEADER-BG-color); 110 | } 111 | 112 | #body .tags a.tag-link:before { 113 | border-right-color: var(--MENU-HEADER-BG-color); 114 | } 115 | 116 | #homelinks { 117 | background: var(--MENU-HEADER-BG-color); 118 | background-color: var(--MENU-HEADER-BORDER-color); 119 | border-bottom-color: var(--MENU-HEADER-BORDER-color); 120 | } 121 | 122 | #homelinks a { 123 | color: var(--MENU-HOME-LINK-color); 124 | } 125 | 126 | #homelinks a:hover { 127 | color: var(--MENU-HOME-LINK-HOVERED-color); 128 | } -------------------------------------------------------------------------------- /site/commentator/public/css/theme-red.css: -------------------------------------------------------------------------------- 1 | 2 | :root{ 3 | 4 | --MAIN-TEXT-color:#323232; /* Color of text by default */ 5 | --MAIN-TITLES-TEXT-color: #5e5e5e; /* Color of titles h2-h3-h4-h5 */ 6 | --MAIN-LINK-color:#f31c1c; /* Color of links */ 7 | --MAIN-LINK-HOVER-color:#d01616; /* Color of hovered links */ 8 | --MAIN-ANCHOR-color: #f31c1c; /* color of anchors on titles */ 9 | 10 | --MENU-HOME-LINK-color: #ccc; /* Color of the home button text */ 11 | --MENU-HOME-LINK-HOVER-color: #e6e6e6; /* Color of the hovered home button text */ 12 | 13 | --MENU-HEADER-BG-color:#dc1010; /* Background color of menu header */ 14 | --MENU-HEADER-BORDER-color:#e23131; /*Color of menu header border */ 15 | 16 | --MENU-SEARCH-BG-color:#b90000; /* Search field background color (by default borders + icons) */ 17 | --MENU-SEARCH-BOX-color: #ef2020; /* Override search field border color */ 18 | --MENU-SEARCH-BOX-ICONS-color: #fda1a1; /* Override search field icons color */ 19 | 20 | --MENU-SECTIONS-ACTIVE-BG-color:#2b2020; /* Background color of the active section and its childs */ 21 | --MENU-SECTIONS-BG-color:#312525; /* Background color of other sections */ 22 | --MENU-SECTIONS-LINK-color: #ccc; /* Color of links in menu */ 23 | --MENU-SECTIONS-LINK-HOVER-color: #e6e6e6; /* Color of links in menu, when hovered */ 24 | --MENU-SECTION-ACTIVE-CATEGORY-color: #777; /* Color of active category text */ 25 | --MENU-SECTION-ACTIVE-CATEGORY-BG-color: #fff; /* Color of background for the active category (only) */ 26 | 27 | --MENU-VISITED-color: #ff3333; /* Color of 'page visited' icons in menu */ 28 | --MENU-SECTION-HR-color: #2b2020; /* Color of
separator in menu */ 29 | 30 | } 31 | 32 | body { 33 | color: var(--MAIN-TEXT-color) !important; 34 | } 35 | 36 | textarea:focus, input[type="email"]:focus, input[type="number"]:focus, input[type="password"]:focus, input[type="search"]:focus, input[type="tel"]:focus, input[type="text"]:focus, input[type="url"]:focus, input[type="color"]:focus, input[type="date"]:focus, input[type="datetime"]:focus, input[type="datetime-local"]:focus, input[type="month"]:focus, input[type="time"]:focus, input[type="week"]:focus, select[multiple=multiple]:focus { 37 | border-color: none; 38 | box-shadow: none; 39 | } 40 | 41 | h2, h3, h4, h5 { 42 | color: var(--MAIN-TITLES-TEXT-color) !important; 43 | } 44 | 45 | a { 46 | color: var(--MAIN-LINK-color); 47 | } 48 | 49 | .anchor { 50 | color: var(--MAIN-ANCHOR-color); 51 | } 52 | 53 | a:hover { 54 | color: var(--MAIN-LINK-HOVER-color); 55 | } 56 | 57 | #sidebar ul li.visited > a .read-icon { 58 | color: var(--MENU-VISITED-color); 59 | } 60 | 61 | #body a.highlight:after { 62 | display: block; 63 | content: ""; 64 | height: 1px; 65 | width: 0%; 66 | -webkit-transition: width 0.5s ease; 67 | -moz-transition: width 0.5s ease; 68 | -ms-transition: width 0.5s ease; 69 | transition: width 0.5s ease; 70 | background-color: var(--MAIN-LINK-HOVER-color); 71 | } 72 | #sidebar { 73 | background-color: var(--MENU-SECTIONS-BG-color); 74 | } 75 | #sidebar #header-wrapper { 76 | background: var(--MENU-HEADER-BG-color); 77 | color: var(--MENU-SEARCH-BOX-color); 78 | border-color: var(--MENU-HEADER-BORDER-color); 79 | } 80 | #sidebar .searchbox { 81 | border-color: var(--MENU-SEARCH-BOX-color); 82 | background: var(--MENU-SEARCH-BG-color); 83 | } 84 | #sidebar ul.topics > li.parent, #sidebar ul.topics > li.active { 85 | background: var(--MENU-SECTIONS-ACTIVE-BG-color); 86 | } 87 | #sidebar .searchbox * { 88 | color: var(--MENU-SEARCH-BOX-ICONS-color); 89 | } 90 | 91 | #sidebar a { 92 | color: var(--MENU-SECTIONS-LINK-color); 93 | } 94 | 95 | #sidebar a:hover { 96 | color: var(--MENU-SECTIONS-LINK-HOVER-color); 97 | } 98 | 99 | #sidebar ul li.active > a { 100 | background: var(--MENU-SECTION-ACTIVE-CATEGORY-BG-color); 101 | color: var(--MENU-SECTION-ACTIVE-CATEGORY-color) !important; 102 | } 103 | 104 | #sidebar hr { 105 | border-color: var(--MENU-SECTION-HR-color); 106 | } 107 | 108 | #body .tags a.tag-link { 109 | background-color: var(--MENU-HEADER-BG-color); 110 | } 111 | 112 | #body .tags a.tag-link:before { 113 | border-right-color: var(--MENU-HEADER-BG-color); 114 | } 115 | 116 | #homelinks { 117 | background: var(--MENU-HEADER-BG-color); 118 | background-color: var(--MENU-HEADER-BORDER-color); 119 | border-bottom-color: var(--MENU-HEADER-BORDER-color); 120 | } 121 | 122 | #homelinks a { 123 | color: var(--MENU-HOME-LINK-color); 124 | } 125 | 126 | #homelinks a:hover { 127 | color: var(--MENU-HOME-LINK-HOVERED-color); 128 | } -------------------------------------------------------------------------------- /site/commentator/public/css/theme-green.css: -------------------------------------------------------------------------------- 1 | 2 | :root{ 3 | 4 | --MAIN-TEXT-color:#323232; /* Color of text by default */ 5 | --MAIN-TITLES-TEXT-color: #5e5e5e; /* Color of titles h2-h3-h4-h5 */ 6 | --MAIN-LINK-color:#599a3e; /* Color of links */ 7 | --MAIN-LINK-HOVER-color:#3f6d2c; /* Color of hovered links */ 8 | --MAIN-ANCHOR-color: #599a3e; /* color of anchors on titles */ 9 | 10 | --MENU-HOME-LINK-color: #323232; /* Color of the home button text */ 11 | --MENU-HOME-LINK-HOVER-color: #5e5e5e; /* Color of the hovered home button text */ 12 | 13 | --MENU-HEADER-BG-color:#74b559; /* Background color of menu header */ 14 | --MENU-HEADER-BORDER-color:#9cd484; /*Color of menu header border */ 15 | 16 | --MENU-SEARCH-BG-color:#599a3e; /* Search field background color (by default borders + icons) */ 17 | --MENU-SEARCH-BOX-color: #84c767; /* Override search field border color */ 18 | --MENU-SEARCH-BOX-ICONS-color: #c7f7c4; /* Override search field icons color */ 19 | 20 | --MENU-SECTIONS-ACTIVE-BG-color:#1b211c; /* Background color of the active section and its childs */ 21 | --MENU-SECTIONS-BG-color:#222723; /* Background color of other sections */ 22 | --MENU-SECTIONS-LINK-color: #ccc; /* Color of links in menu */ 23 | --MENU-SECTIONS-LINK-HOVER-color: #e6e6e6; /* Color of links in menu, when hovered */ 24 | --MENU-SECTION-ACTIVE-CATEGORY-color: #777; /* Color of active category text */ 25 | --MENU-SECTION-ACTIVE-CATEGORY-BG-color: #fff; /* Color of background for the active category (only) */ 26 | 27 | --MENU-VISITED-color: #599a3e; /* Color of 'page visited' icons in menu */ 28 | --MENU-SECTION-HR-color: #18211c; /* Color of
separator in menu */ 29 | 30 | } 31 | 32 | body { 33 | color: var(--MAIN-TEXT-color) !important; 34 | } 35 | 36 | textarea:focus, input[type="email"]:focus, input[type="number"]:focus, input[type="password"]:focus, input[type="search"]:focus, input[type="tel"]:focus, input[type="text"]:focus, input[type="url"]:focus, input[type="color"]:focus, input[type="date"]:focus, input[type="datetime"]:focus, input[type="datetime-local"]:focus, input[type="month"]:focus, input[type="time"]:focus, input[type="week"]:focus, select[multiple=multiple]:focus { 37 | border-color: none; 38 | box-shadow: none; 39 | } 40 | 41 | h2, h3, h4, h5 { 42 | color: var(--MAIN-TITLES-TEXT-color) !important; 43 | } 44 | 45 | a { 46 | color: var(--MAIN-LINK-color); 47 | } 48 | 49 | .anchor { 50 | color: var(--MAIN-ANCHOR-color); 51 | } 52 | 53 | a:hover { 54 | color: var(--MAIN-LINK-HOVER-color); 55 | } 56 | 57 | #sidebar ul li.visited > a .read-icon { 58 | color: var(--MENU-VISITED-color); 59 | } 60 | 61 | #body a.highlight:after { 62 | display: block; 63 | content: ""; 64 | height: 1px; 65 | width: 0%; 66 | -webkit-transition: width 0.5s ease; 67 | -moz-transition: width 0.5s ease; 68 | -ms-transition: width 0.5s ease; 69 | transition: width 0.5s ease; 70 | background-color: var(--MAIN-LINK-HOVER-color); 71 | } 72 | #sidebar { 73 | background-color: var(--MENU-SECTIONS-BG-color); 74 | } 75 | #sidebar #header-wrapper { 76 | background: var(--MENU-HEADER-BG-color); 77 | color: var(--MENU-SEARCH-BOX-color); 78 | border-color: var(--MENU-HEADER-BORDER-color); 79 | } 80 | #sidebar .searchbox { 81 | border-color: var(--MENU-SEARCH-BOX-color); 82 | background: var(--MENU-SEARCH-BG-color); 83 | } 84 | #sidebar ul.topics > li.parent, #sidebar ul.topics > li.active { 85 | background: var(--MENU-SECTIONS-ACTIVE-BG-color); 86 | } 87 | #sidebar .searchbox * { 88 | color: var(--MENU-SEARCH-BOX-ICONS-color); 89 | } 90 | 91 | #sidebar a { 92 | color: var(--MENU-SECTIONS-LINK-color); 93 | } 94 | 95 | #sidebar a:hover { 96 | color: var(--MENU-SECTIONS-LINK-HOVER-color); 97 | } 98 | 99 | #sidebar ul li.active > a { 100 | background: var(--MENU-SECTION-ACTIVE-CATEGORY-BG-color); 101 | color: var(--MENU-SECTION-ACTIVE-CATEGORY-color) !important; 102 | } 103 | 104 | #sidebar hr { 105 | border-color: var(--MENU-SECTION-HR-color); 106 | } 107 | 108 | #body .tags a.tag-link { 109 | background-color: var(--MENU-HEADER-BG-color); 110 | } 111 | 112 | #body .tags a.tag-link:before { 113 | border-right-color: var(--MENU-HEADER-BG-color); 114 | } 115 | 116 | #homelinks { 117 | background: var(--MENU-HEADER-BG-color); 118 | background-color: var(--MENU-HEADER-BORDER-color); 119 | border-bottom-color: var(--MENU-HEADER-BORDER-color); 120 | } 121 | 122 | #homelinks a { 123 | color: var(--MENU-HOME-LINK-color); 124 | } 125 | 126 | #homelinks a:hover { 127 | color: var(--MENU-HOME-LINK-HOVERED-color); 128 | } -------------------------------------------------------------------------------- /site/commentator/public/css/theme-commentator.css: -------------------------------------------------------------------------------- 1 | 2 | :root{ 3 | 4 | --MAIN-TEXT-color:#323232; /* Color of text by default */ 5 | --MAIN-TITLES-TEXT-color: #5e5e5e; /* Color of titles h2-h3-h4-h5 */ 6 | --MAIN-LINK-color: #0007c9; /* Color of links */ 7 | --MAIN-LINK-HOVER-color:#0007c9; /* Color of hovered links */ 8 | --MAIN-ANCHOR-color: #1C90F3; /* color of anchors on titles */ 9 | 10 | --MENU-HOME-LINK-color: #323232; /* Color of the home button text */ 11 | --MENU-HOME-LINK-HOVER-color: #5e5e5e; /* Color of the hovered home button text */ 12 | 13 | --MENU-HEADER-BG-color: #0199b1 ; /* Background color of menu header */ 14 | --MENU-HEADER-BORDER-color :#fff; /*Color of menu header border */ 15 | 16 | --MENU-SEARCH-BG-color: #e6e6e6; /* Search field background color (by default borders + icons) */ 17 | --MENU-SEARCH-BOX-color: #33a1ff; /* Override search field border color */ 18 | --MENU-SEARCH-BOX-ICONS-color: #33a1ff; /* Override search field icons color */ 19 | 20 | --MENU-SECTIONS-ACTIVE-BG-color:#20272b; /* Background color of the active section and its childs */ 21 | --MENU-SECTIONS-BG-color:#252c31; /* Background color of other sections */ 22 | --MENU-SECTIONS-LINK-color: #ccc; /* Color of links in menu */ 23 | --MENU-SECTIONS-LINK-HOVER-color: #e6e6e6; /* Color of links in menu, when hovered */ 24 | --MENU-SECTION-ACTIVE-CATEGORY-color: black; /* Color of active category text */ 25 | --MENU-SECTION-ACTIVE-CATEGORY-BG-color: #fff; /* Color of background for the active category (only) */ 26 | 27 | --MENU-VISITED-color: #33a1ff; /* Color of 'page visited' icons in menu */ 28 | --MENU-SECTION-HR-color: #20272b; /* Color of
separator in menu */ 29 | 30 | } 31 | 32 | body { 33 | color: var(--MAIN-TEXT-color) !important; 34 | } 35 | 36 | textarea:focus, input[type="email"]:focus, input[type="number"]:focus, input[type="password"]:focus, input[type="search"]:focus, input[type="tel"]:focus, input[type="text"]:focus, input[type="url"]:focus, input[type="color"]:focus, input[type="date"]:focus, input[type="datetime"]:focus, input[type="datetime-local"]:focus, input[type="month"]:focus, input[type="time"]:focus, input[type="week"]:focus, select[multiple=multiple]:focus { 37 | border-color: none; 38 | box-shadow: none; 39 | } 40 | 41 | h2, h3, h4, h5 { 42 | color: var(--MAIN-TITLES-TEXT-color) !important; 43 | } 44 | 45 | a { 46 | color: var(--MAIN-LINK-color); 47 | } 48 | 49 | .anchor { 50 | color: var(--MAIN-ANCHOR-color); 51 | } 52 | 53 | a:hover { 54 | color: var(--MAIN-LINK-HOVER-color); 55 | } 56 | 57 | #sidebar ul li.visited > a .read-icon { 58 | color: var(--MENU-VISITED-color); 59 | } 60 | 61 | #body a.highlight:after { 62 | display: block; 63 | content: ""; 64 | height: 1px; 65 | width: 0%; 66 | -webkit-transition: width 0.5s ease; 67 | -moz-transition: width 0.5s ease; 68 | -ms-transition: width 0.5s ease; 69 | transition: width 0.5s ease; 70 | background-color: var(--MAIN-LINK-HOVER-color); 71 | } 72 | #sidebar { 73 | background-color: var(--MENU-SECTIONS-BG-color); 74 | } 75 | #sidebar #header-wrapper { 76 | background: var(--MENU-HEADER-BG-color); 77 | color: var(--MENU-SEARCH-BOX-color); 78 | border-color: var(--MENU-HEADER-BORDER-color); 79 | } 80 | #sidebar .searchbox { 81 | border-color: var(--MENU-SEARCH-BOX-color); 82 | background: var(--MENU-SEARCH-BG-color); 83 | } 84 | #sidebar ul.topics > li.parent, #sidebar ul.topics > li.active { 85 | background: var(--MENU-SECTIONS-ACTIVE-BG-color); 86 | } 87 | #sidebar .searchbox * { 88 | color: var(--MENU-SEARCH-BOX-ICONS-color); 89 | } 90 | 91 | #sidebar a { 92 | color: var(--MENU-SECTIONS-LINK-color); 93 | } 94 | 95 | #sidebar a:hover { 96 | color: var(--MENU-SECTIONS-LINK-HOVER-color); 97 | } 98 | 99 | #sidebar ul li.active > a { 100 | background: var(--MENU-SECTION-ACTIVE-CATEGORY-BG-color); 101 | color: var(--MENU-SECTION-ACTIVE-CATEGORY-color) !important; 102 | } 103 | 104 | #sidebar hr { 105 | border-color: var(--MENU-SECTION-HR-color); 106 | } 107 | 108 | #body .tags a.tag-link { 109 | background-color: var(--MENU-HEADER-BG-color); 110 | } 111 | 112 | #body .tags a.tag-link:before { 113 | border-right-color: var(--MENU-HEADER-BG-color); 114 | } 115 | 116 | #homelinks { 117 | background: var(--MENU-HEADER-BG-color); 118 | background-color: var(--MENU-HEADER-BORDER-color); 119 | border-bottom-color: var(--MENU-HEADER-BORDER-color); 120 | } 121 | 122 | #homelinks a { 123 | color: var(--MENU-HOME-LINK-color); 124 | } 125 | 126 | #homelinks a:hover { 127 | color: var(--MENU-HOME-LINK-HOVERED-color); 128 | } 129 | -------------------------------------------------------------------------------- /src/commentator/api.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.api 2 | (:require [clojure.spec.alpha :as s] 3 | [commentator.comment :as comment] 4 | [commentator.config :as config] 5 | [commentator.event :as event] 6 | [commentator.handler :as handler] 7 | [commentator.spec :as spec])) 8 | 9 | (s/def ::answer ::spec/non-empty-string) 10 | (s/def ::comment-id ::comment/id) 11 | (s/def ::article ::spec/non-empty-string) 12 | (s/def ::timestamp pos-int?) 13 | (s/def ::signature ::spec/non-empty-string) 14 | 15 | (s/def ::path ::spec/non-empty-string) 16 | (s/def :usage/add (s/keys :req-un [::config/website 17 | ::path])) 18 | 19 | (s/def :comment/new (s/keys :req-un [::article 20 | ::comment/author 21 | ::comment/content 22 | ::config/website 23 | ::answer 24 | ::timestamp 25 | ::signature] 26 | :opt-un [::comment/author-website])) 27 | (s/def :comment/get (s/keys :req-un [::article 28 | ::config/website 29 | ::comment-id])) 30 | (s/def :comment/for-article (s/keys :req-un [::article 31 | ::config/website])) 32 | (s/def :comment/approve (s/keys :req-un [::article ::comment-id ::config/website])) 33 | (s/def :comment/delete (s/keys :req-un [::article ::comment-id ::config/website])) 34 | (s/def :comment/delete-article (s/keys :req-un [::article ::config/website])) 35 | (s/def :comment/admin-for-article (s/keys :req-un [::article ::config/website])) 36 | (s/def :challenge/random (s/keys :req-un [::article ::config/website])) 37 | 38 | (s/def :event/delete (s/keys :req-un [::event/id ::config/website])) 39 | (s/def :usage/day ::spec/non-empty-string) 40 | (s/def :usage/month ::spec/non-empty-string) 41 | (s/def :usage/year ::spec/non-empty-string) 42 | (s/def :usage/get-for-day (s/keys :req-un [::config/website 43 | :usage/day 44 | :usage/month 45 | :usage/year])) 46 | (s/def :event/list (s/keys :req-un [::config/website])) 47 | 48 | (def router 49 | [["/api/v1/comment/:website/:article" {:post {:spec :comment/new 50 | :handler handler/new-comment} 51 | :get {:spec :comment/for-article 52 | :handler handler/comments-for-article}}] 53 | ["/api/admin/comment/:website/:article" {:get {:handler handler/admin-for-article 54 | :auth true 55 | :spec :comment/admin-for-article} 56 | :delete {:spec :comment/delete-article 57 | :auth true 58 | :handler handler/delete-article-comments}}] 59 | ["/api/admin/comment/:website/:article/:comment-id" {:get {:spec :comment/get 60 | :auth true 61 | :handler handler/get-comment} 62 | :delete {:spec :comment/delete 63 | :auth true 64 | :handler handler/delete-comment} 65 | :post {:spec :comment/approve 66 | :auth true 67 | :handler handler/approve-comment}}] 68 | ["/api/v1/challenge/:website/:article" {:get {:handler handler/random-challenge 69 | :spec :challenge/random}}] 70 | ["/api/admin/event/:website" {:get {:spec :event/list 71 | :auth true 72 | :handler handler/list-events}}] 73 | ["/api/admin/event/:website/:id" {:delete {:spec :event/delete 74 | :auth true 75 | :handler handler/delete-event}}] 76 | ["/api/v1/usage/:website" {:post {:spec :usage/add 77 | :handler handler/usage}}] 78 | ["/api/admin/usage/:website/:year/:month/:day" {:get {:spec :usage/get-for-day 79 | :auth true 80 | :handler handler/get-usage-for-day}}] 81 | ["/healthz" {:get {:handler handler/healthz}}]]) 82 | -------------------------------------------------------------------------------- /src/commentator/usage.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.usage 2 | (:require [cheshire.core :as json] 3 | [com.stuartsierra.component :as component] 4 | [commentator.rate-limit :as rl] 5 | [commentator.store :as store] 6 | [corbihttp.log :as log] 7 | [exoscale.ex :as ex]) 8 | (:import java.util.concurrent.ScheduledThreadPoolExecutor 9 | java.util.concurrent.TimeUnit 10 | java.time.LocalDate 11 | java.time.format.DateTimeFormatter)) 12 | 13 | (defprotocol IWebsiteUsage 14 | (new-request [this request] "Add this request to the website usage component") 15 | (usage-for-day [this website year month day] "Get the usage per article for the given day")) 16 | 17 | (def ^DateTimeFormatter formatter (DateTimeFormatter/ofPattern "yyyy/MM/dd")) 18 | 19 | (defn now 20 | [] 21 | (.format (LocalDate/now) formatter)) 22 | 23 | (defn add-request 24 | [cache request] 25 | (let [ip (rl/source-ip request) 26 | website (get-in request [:all-params :website]) 27 | path (get-in request [:all-params :path]) 28 | day (now)] 29 | (if (get-in cache [website day path ip]) 30 | (update-in cache [website day path ip] inc) 31 | (assoc-in cache [website day path ip] 1)))) 32 | 33 | (defn resource-name 34 | [day] 35 | (str "usage/" day)) 36 | 37 | (defn sync-cache 38 | [cache s3] 39 | (try 40 | (doseq [[website website-usage] @cache] 41 | (doseq [[day content] website-usage] 42 | (log/infof {} 43 | "sync usage cache for %s - %s" 44 | website 45 | day) 46 | (store/save-resource s3 47 | website 48 | (resource-name day) 49 | (json/generate-string content)))) 50 | (catch Exception e 51 | (log/error {} e "fail to sync cache")))) 52 | 53 | (defn load-cache 54 | [cache s3 website] 55 | (log/info {:website website} "loading usage cache") 56 | (let [day (now) 57 | day-resource (resource-name day)] 58 | (when (store/exists? s3 website day-resource) 59 | (swap! cache assoc-in [website day] (-> (store/get-resource s3 website day-resource) 60 | (json/parse-string)))))) 61 | 62 | (defn purge-cache 63 | [cache] 64 | (log/info {} "purge usage cache") 65 | (try 66 | (->> (map (fn [[website website-cache]] 67 | [website (select-keys website-cache [(now)])]) 68 | cache) 69 | (into {})) 70 | (catch Exception e 71 | (log/error {} e "fail to purge cache")))) 72 | 73 | (defn get-usage 74 | [website s3 day] 75 | (let [path (resource-name day)] 76 | (if (store/exists? s3 website path) 77 | (-> (store/get-resource s3 website path) 78 | (json/parse-string false)) 79 | (ex/ex-incorrect! (format "usage for day %s not found" day) 80 | {})))) 81 | 82 | (defn compute-usage-for-day 83 | [usage] 84 | (reduce 85 | (fn [state [url accesses]] 86 | (let [unique-page (count accesses) 87 | total-page (reduce #(+ %1 (second %2)) 0 accesses)] 88 | (-> (assoc-in state [:pages url] 89 | {:unique unique-page 90 | :total total-page}) 91 | (update :unique + unique-page) 92 | (update :total + total-page)))) 93 | {:unique 0 94 | :total 0} 95 | usage)) 96 | 97 | (defrecord WebsiteUsage [websites s3 cache executor] 98 | component/Lifecycle 99 | (start [this] 100 | (let [executor (ScheduledThreadPoolExecutor. 1) 101 | c (atom {})] 102 | (doseq [website websites] 103 | (load-cache c s3 website)) 104 | (.scheduleWithFixedDelay executor 105 | ^Runnable (fn [] 106 | (sync-cache c s3)) 107 | 20 108 | 60 109 | TimeUnit/SECONDS) 110 | (.scheduleWithFixedDelay executor 111 | ^Runnable (fn [] 112 | (swap! c purge-cache)) 113 | 60 114 | 14400 115 | TimeUnit/SECONDS) 116 | (assoc this :cache c :executor executor))) 117 | 118 | (stop [this] 119 | (when executor 120 | (.shutdown executor) 121 | (.awaitTermination executor 122 | 20 123 | TimeUnit/SECONDS) 124 | ;; sync one last time 125 | (sync-cache cache s3)) 126 | (assoc this :cache nil :executor nil)) 127 | IWebsiteUsage 128 | (new-request [_ request] 129 | (swap! cache add-request request)) 130 | (usage-for-day [_ website year month day] 131 | (-> (get-usage website s3 (format "%s/%s/%s" year month day)) 132 | compute-usage-for-day))) 133 | -------------------------------------------------------------------------------- /site/commentator/content/howto/use-it/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Use it 3 | weight: 50 4 | disableToc: false 5 | --- 6 | 7 | Commentator stores the articles comments on a S3 compatible store. + 8 | Let's say we want to store comments for the blog `mcorbin.fr` for example. 9 | 10 | The first thing to do is to write the Commentator [configuration file](/howto/configuration/). 11 | 12 | As explained in the linked section of the documentation, several things are important. 13 | 14 | **Allow origin** 15 | 16 | The same Commentator instance can serve comments for several websites. You need to configure the list of websites in the `:allow-origin` section. 17 | 18 | **Rate limit** 19 | 20 | The `:rate-limit-minutes` option prevents users to create more than one comment every N minutes. 21 | 22 | The `x-forwarded-for` header is first used to get the user IP. If the header is not set it fallbacks to the request source IP. 23 | 24 | **Comment** 25 | 26 | Comments can be automatically approved by setting the `:auto-approve` value to `true`. By default, comments not approved by an administrator are not displayed. 27 | 28 | You also need to configure the `allowed-articles` key. It contains a map containing for each website the list of articles allowed to receive comments. For example: 29 | 30 | ```clojure 31 | {"mcorbin-fr" ["my-first-article" 32 | "my-second-article"]} 33 | ``` 34 | 35 | With this setup, only requests to create comments on the `/api/v1/comment//
` path will be allowed, where the possible value for `` is "mcorbin-fr" and the possible values for `
` are "my-first-article" and "my-second-article". 36 | 37 | You can read the [API documentation](/api/comments/) to understand how it works. It's important to keep in mind that the `allowed-articles` key should match how the API is used later to store and retrieve comments for articles. 38 | 39 | **Challenges** 40 | 41 | Commentator supports a basic challenge system to avoid spammers. 42 | 43 | Commentator provides an [API endpoint](/api/comments/) to return a random challenge. This API endpoint can be used to integrate Commentator on your website. What is returned by the endpoint is a json payload containing: 44 | 45 | - A `question`, a text containing a question 46 | - A `timestamp` 47 | - A `signature` 48 | 49 | When users want to create a comment, they should provide: 50 | 51 | - The `timestamp` and the `signature` provided by the previous endpoint. 52 | - An `answer` field containing the expected answer (case insensitive). 53 | 54 | The `:challenges` key in the configuration can be used to configure challenges. It currently supports two modes. 55 | 56 | ### questions 57 | 58 | ```clojure 59 | {:type :questions 60 | :ttl 120 61 | :secret #secret "azizjiuzarhuaizhaiuzr" 62 | :questions [{:question "1 + 4 = ?" 63 | :answer "5"} 64 | {:question "1 + 9 = ?" 65 | :answer "10"}]} 66 | ``` 67 | 68 | We have here two challenges with simple questions about mathematical operations. The questions and answers are totally free, it's up to you to choose what you want to ask. 69 | 70 | You can also easily generate a lot of challenges programmatically if you want to. 71 | 72 | When users want to create a comment, they should provide: 73 | 74 | challenge name and the correct answer. The case of the letters in the answer is not important, everything is compared after being converted to lower case. 75 | 76 | The TTL is the validity duration of the challenge. 77 | 78 | ### math 79 | 80 | ```clojure 81 | {:type :math 82 | :ttl 120 83 | :secret #secret "azizjiuzarhuaizhaiuzr"} 84 | ``` 85 | 86 | This challenge will automatically generate simple mathematical challenges. It could for example return a `:question` containing `"what is the result of: 10 + 6"` 87 | 88 | The user should, like in the `questions` challenge, provide the answer, timestamp and signature when creating a comment. 89 | 90 | **Store** 91 | 92 | You should put the store configuration (to access the S3 storage) in the `:store` key. 93 | 94 | The `:bucket-prefix` value will be used as prefix for the buckets storing the comments. For example, if `:bucket-prefix` is set to "commentator-", the bucket will be "commentator-mcorbin-fr" for the `mcorbin-fr` website. 95 | 96 | The buckets should already exist, Commentator will not create them automatically. 97 | 98 | ## Events 99 | 100 | Every time a comment is published, an event is pushed in the website bucket in a file named `events.json`. 101 | 102 | The [API](/api/events/) let you retrieve and delete these events. You can use this file or these endpoints to be notified when a new comment is created. 103 | 104 | ## Integration 105 | 106 | The code I use to integrate Commentator on my personal blog is available on [Github](https://github.com/mcorbin/commentator/blob/master/integration/mcorbin/page.html). 107 | 108 | I'm not a frontend developer. If you have frontend skills and want to write a nice integration, please reach to me on Github. 109 | -------------------------------------------------------------------------------- /src/commentator/core.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.core 2 | (:require [com.stuartsierra.component :as component] 3 | [commentator.cache :as c] 4 | [commentator.comment :as comment] 5 | [commentator.config :as config] 6 | commentator.challenge 7 | [commentator.event :as event] 8 | [commentator.handler :as handler] 9 | [commentator.chain :as chain] 10 | [commentator.lock :as lock] 11 | [commentator.rate-limit :as rate-limit] 12 | [commentator.store :as store] 13 | [commentator.usage :as usage] 14 | [corbihttp.http :as corbihttp] 15 | [corbihttp.log :as log] 16 | [corbihttp.metric :as metric] 17 | [signal.handler :refer [with-handler]] 18 | [unilog.config :refer [start-logging!]]) 19 | (:import commentator.challenge.ChallengeManager) 20 | (:gen-class)) 21 | 22 | (defonce ^:redef system 23 | nil) 24 | 25 | (defn allowed-articles-set 26 | [comment-config] 27 | (if (:allowed-articles comment-config) 28 | (update comment-config 29 | :allowed-articles 30 | #(when % 31 | (into {} 32 | (for [[k v] %] 33 | [(name k) (set v)])))) 34 | comment-config)) 35 | 36 | (defn build-system 37 | [{:keys [http 38 | admin 39 | store 40 | comment 41 | challenges 42 | prometheus 43 | rate-limit-minutes 44 | allow-origin]}] 45 | (let [registry (metric/registry-component {}) 46 | challenges (assoc challenges :key-spec 47 | true)] 48 | (component/system-map 49 | :lock (lock/map->Lock {}) 50 | :registry registry 51 | :http (-> (corbihttp/map->Server (merge {:config http 52 | :chain-builder chain/interceptor-chain 53 | :registry registry 54 | :allow-origin (set allow-origin)} 55 | admin)) 56 | (component/using [:api-handler])) 57 | :s3 (store/map->S3 {:credentials (dissoc store :bucket) 58 | :bucket-prefix (:bucket-prefix store)}) 59 | :rate-limiter (rate-limit/map->SimpleRateLimiter {:rate-limit-minutes rate-limit-minutes}) 60 | :challenge-manager (ChallengeManager. challenges) 61 | :event-manager (-> (event/map->EventManager {}) 62 | (component/using [:s3 :lock])) 63 | :comment-manager (-> (comment/map->CommentManager (allowed-articles-set 64 | comment)) 65 | (component/using [:s3 :cache :lock])) 66 | :prometheus (if (and prometheus (seq prometheus)) 67 | (corbihttp/map->Server {:config prometheus 68 | :registry registry 69 | :chain-builder metric/prom-chain-builder}) 70 | {}) 71 | :cache (c/map->MemoryCache {}) 72 | :usage-component (-> (usage/map->WebsiteUsage {:websites (-> comment 73 | :allowed-articles 74 | keys)}) 75 | (component/using [:s3])) 76 | :api-handler (-> (handler/map->Handler {:challenges challenges}) 77 | (component/using [:event-manager :comment-manager :challenge-manager :rate-limiter :usage-component]))))) 78 | 79 | (defn init-system 80 | "Initialize system, dropping the previous state." 81 | [config] 82 | (let [sys (build-system config)] 83 | (alter-var-root #'system (constantly sys)))) 84 | 85 | (defn stop! 86 | "Stop the system." 87 | [] 88 | (let [sys (component/stop-system system)] 89 | (alter-var-root #'system (constantly sys)))) 90 | 91 | (defn start! 92 | "Start the system." 93 | [] 94 | (try 95 | (let [config (config/load-config) 96 | _ (start-logging! (:logging config)) 97 | _ (init-system config) 98 | sys (component/start-system system)] 99 | (alter-var-root #'system (constantly sys))) 100 | (catch Exception e 101 | (log/error {} e "fail to start the system") 102 | (throw e)))) 103 | 104 | (defn -main 105 | "Starts the application" 106 | [& args] 107 | (with-handler :term 108 | (log/info {} "SIGTERM, stopping") 109 | (stop!) 110 | (log/info {} "the system is stopped") 111 | (System/exit 0)) 112 | 113 | (with-handler :int 114 | (log/info {} "SIGINT, stopping") 115 | (stop!) 116 | (log/info {} "the system is stopped") 117 | (System/exit 0)) 118 | (try (start!) 119 | (log/info {} "Mais y connaît pas Raoul ce mec ! Y va avoir un réveil pénible...") 120 | (log/info {} "system started") 121 | (catch Exception e 122 | (System/exit 1)))) 123 | -------------------------------------------------------------------------------- /site/commentator/public/js/auto-complete.js: -------------------------------------------------------------------------------- 1 | // JavaScript autoComplete v1.0.4 2 | // https://github.com/Pixabay/JavaScript-autoComplete 3 | var autoComplete=function(){function e(e){function t(e,t){return e.classList?e.classList.contains(t):new RegExp("\\b"+t+"\\b").test(e.className)}function o(e,t,o){e.attachEvent?e.attachEvent("on"+t,o):e.addEventListener(t,o)}function s(e,t,o){e.detachEvent?e.detachEvent("on"+t,o):e.removeEventListener(t,o)}function n(e,s,n,l){o(l||document,s,function(o){for(var s,l=o.target||o.srcElement;l&&!(s=t(l,e));)l=l.parentElement;s&&n.call(l,o)})}if(document.querySelector){var l={selector:0,source:0,minChars:3,delay:150,offsetLeft:0,offsetTop:1,cache:1,menuClass:"",renderItem:function(e,t){t=t.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&");var o=new RegExp("("+t.split(" ").join("|")+")","gi");return'
'+e.replace(o,"$1")+"
"},onSelect:function(){}};for(var c in e)e.hasOwnProperty(c)&&(l[c]=e[c]);for(var a="object"==typeof l.selector?[l.selector]:document.querySelectorAll(l.selector),u=0;u0?i.sc.scrollTop=n+i.sc.suggestionHeight+s-i.sc.maxHeight:0>n&&(i.sc.scrollTop=n+s)}else i.sc.scrollTop=0},o(window,"resize",i.updateSC),document.body.appendChild(i.sc),n("autocomplete-suggestion","mouseleave",function(){var e=i.sc.querySelector(".autocomplete-suggestion.selected");e&&setTimeout(function(){e.className=e.className.replace("selected","")},20)},i.sc),n("autocomplete-suggestion","mouseover",function(){var e=i.sc.querySelector(".autocomplete-suggestion.selected");e&&(e.className=e.className.replace("selected","")),this.className+=" selected"},i.sc),n("autocomplete-suggestion","mousedown",function(e){if(t(this,"autocomplete-suggestion")){var o=this.getAttribute("data-val");i.value=o,l.onSelect(e,o,this),i.sc.style.display="none"}},i.sc),i.blurHandler=function(){try{var e=document.querySelector(".autocomplete-suggestions:hover")}catch(t){var e=0}e?i!==document.activeElement&&setTimeout(function(){i.focus()},20):(i.last_val=i.value,i.sc.style.display="none",setTimeout(function(){i.sc.style.display="none"},350))},o(i,"blur",i.blurHandler);var r=function(e){var t=i.value;if(i.cache[t]=e,e.length&&t.length>=l.minChars){for(var o="",s=0;st||t>40)&&13!=t&&27!=t){var o=i.value;if(o.length>=l.minChars){if(o!=i.last_val){if(i.last_val=o,clearTimeout(i.timer),l.cache){if(o in i.cache)return void r(i.cache[o]);for(var s=1;sEventManager {:s3 store 29 | :lock (component/start (lock/map->Lock {}))})] 30 | (is (= events (event/list-events mng website))))) 31 | (testing "No events" 32 | (let [store (ms/store-mock {:exists? (constantly false)}) 33 | mng (event/map->EventManager {:s3 store})] 34 | (is (= [] (event/list-events mng website)))))) 35 | 36 | (deftest add-event-test 37 | (testing "some events exist" 38 | (let [events [{:id (UUID/randomUUID) 39 | :timestamp (System/currentTimeMillis) 40 | :type :new-comment} 41 | {:id (UUID/randomUUID) 42 | :timestamp (System/currentTimeMillis) 43 | :type :new-comment}] 44 | event {:id (UUID/randomUUID) 45 | :timestamp (System/currentTimeMillis) 46 | :type :new-comment} 47 | store (ms/store-mock {:get-resource (constantly (js events)) 48 | :save-resource (constantly true) 49 | :exists? (constantly true)}) 50 | mng (event/map->EventManager {:s3 store 51 | :lock (component/start (lock/map->Lock {}))})] 52 | (event/add-event mng website event) 53 | (assert/called-with? (:save-resource (protocol/spies store)) 54 | store 55 | website 56 | event/event-file-name 57 | (json/generate-string (conj events event))))) 58 | (testing "No events" 59 | (let [store (ms/store-mock {:exists? (constantly false) 60 | :save-resource (constantly true)}) 61 | event {:id (UUID/randomUUID) 62 | :timestamp (System/currentTimeMillis) 63 | :type :new-comment} 64 | mng (event/map->EventManager {:s3 store 65 | :lock (component/start (lock/map->Lock {}))})] 66 | (event/add-event mng website event) 67 | (assert/called-with? (:save-resource (protocol/spies store)) 68 | store 69 | website 70 | event/event-file-name 71 | (json/generate-string [event]))))) 72 | 73 | (deftest delete-events-test 74 | (testing "The event exists" 75 | (let [id (UUID/randomUUID) 76 | events [{:id id 77 | :timestamp (System/currentTimeMillis) 78 | :type :new-comment} 79 | {:id (UUID/randomUUID) 80 | :timestamp (System/currentTimeMillis) 81 | :type :new-comment}] 82 | store (ms/store-mock {:get-resource (constantly (js events)) 83 | :save-resource (constantly true) 84 | :exists? (constantly true)}) 85 | mng (event/map->EventManager {:s3 store 86 | :lock (component/start (lock/map->Lock {}))})] 87 | (event/delete-event mng website id) 88 | (assert/called-with? (:save-resource (protocol/spies store)) 89 | store 90 | website 91 | event/event-file-name 92 | (json/generate-string [(second events)])))) 93 | (testing "No event" 94 | (let [id (UUID/randomUUID) 95 | store (ms/store-mock {:exists? (constantly false)}) 96 | mng (event/map->EventManager {:s3 store 97 | :lock (component/start (lock/map->Lock {}))})] 98 | (is (thrown-with-msg? 99 | Exception 100 | #"not found" 101 | (event/delete-event mng website id))))) 102 | (testing "Even does not exist" 103 | (let [id (UUID/randomUUID) 104 | events [{:id (UUID/randomUUID) 105 | :timestamp (System/currentTimeMillis) 106 | :type :new-comment} 107 | {:id (UUID/randomUUID) 108 | :timestamp (System/currentTimeMillis) 109 | :type :new-comment}] 110 | store (ms/store-mock {:get-resource (constantly (js events)) 111 | :exists? (constantly false)}) 112 | mng (event/map->EventManager {:s3 store 113 | :lock (component/start (lock/map->Lock {}))})] 114 | (is (thrown-with-msg? 115 | Exception 116 | #"not found" 117 | (event/delete-event mng website id)))))) 118 | -------------------------------------------------------------------------------- /site/commentator/public/css/hugo-theme.css: -------------------------------------------------------------------------------- 1 | /* Insert here special css for hugo theme, on top of any other imported css */ 2 | 3 | 4 | /* Table of contents */ 5 | 6 | .progress ul { 7 | list-style: none; 8 | margin: 0; 9 | padding: 0 15px; 10 | } 11 | 12 | #TableOfContents { 13 | font-size: 13px !important; 14 | max-height: 85vh; 15 | overflow: auto; 16 | padding: 15px 5px !important; 17 | } 18 | 19 | #TableOfContents > ul > li > a { 20 | font-weight: bold; 21 | } 22 | 23 | body { 24 | font-size: 16px !important; 25 | color: #323232 !important; 26 | } 27 | 28 | #body a.highlight, #body a.highlight:hover, #body a.highlight:focus { 29 | text-decoration: none; 30 | outline: none; 31 | outline: 0; 32 | } 33 | #body a.highlight { 34 | line-height: 1.1; 35 | display: inline-block; 36 | } 37 | #body a.highlight:after { 38 | display: block; 39 | content: ""; 40 | height: 1px; 41 | width: 0%; 42 | background-color: #0082a7; /*#CE3B2F*/ 43 | -webkit-transition: width 0.5s ease; 44 | -moz-transition: width 0.5s ease; 45 | -ms-transition: width 0.5s ease; 46 | transition: width 0.5s ease; 47 | } 48 | #body a.highlight:hover:after, #body a.highlight:focus:after { 49 | width: 100%; 50 | } 51 | .progress { 52 | position:absolute; 53 | background-color: rgba(246, 246, 246, 0.97); 54 | width: auto; 55 | border: thin solid #ECECEC; 56 | display:none; 57 | z-index:200; 58 | } 59 | 60 | #toc-menu { 61 | border-right: thin solid #DAD8D8 !important; 62 | padding-right: 1rem !important; 63 | margin-right: 0.5rem !important; 64 | } 65 | 66 | #sidebar-toggle-span { 67 | border-right: thin solid #DAD8D8 !important; 68 | padding-right: 0.5rem !important; 69 | margin-right: 1rem !important; 70 | } 71 | 72 | .btn { 73 | display: inline-block !important; 74 | padding: 6px 12px !important; 75 | margin-bottom: 0 !important; 76 | font-size: 14px !important; 77 | font-weight: normal !important; 78 | line-height: 1.42857143 !important; 79 | text-align: center !important; 80 | white-space: nowrap !important; 81 | vertical-align: middle !important; 82 | -ms-touch-action: manipulation !important; 83 | touch-action: manipulation !important; 84 | cursor: pointer !important; 85 | -webkit-user-select: none !important; 86 | -moz-user-select: none !important; 87 | -ms-user-select: none !important; 88 | user-select: none !important; 89 | background-image: none !important; 90 | border: 1px solid transparent !important; 91 | border-radius: 4px !important; 92 | -webkit-transition: all 0.15s !important; 93 | -moz-transition: all 0.15s !important; 94 | transition: all 0.15s !important; 95 | } 96 | .btn:focus { 97 | /*outline: thin dotted; 98 | outline: 5px auto -webkit-focus-ring-color; 99 | outline-offset: -2px;*/ 100 | outline: none !important; 101 | } 102 | .btn:hover, 103 | .btn:focus { 104 | color: #2b2b2b !important; 105 | text-decoration: none !important; 106 | } 107 | 108 | .btn-default { 109 | color: #333 !important; 110 | background-color: #fff !important; 111 | border-color: #ccc !important; 112 | } 113 | .btn-default:hover, 114 | .btn-default:focus, 115 | .btn-default:active { 116 | color: #fff !important; 117 | background-color: #9e9e9e !important; 118 | border-color: #9e9e9e !important; 119 | } 120 | .btn-default:active { 121 | background-image: none !important; 122 | } 123 | 124 | /* anchors */ 125 | .anchor { 126 | color: #00bdf3; 127 | font-size: 0.5em; 128 | cursor:pointer; 129 | visibility:hidden; 130 | margin-left: 0.5em; 131 | position: absolute; 132 | margin-top:0.1em; 133 | } 134 | 135 | h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, h5:hover .anchor, h6:hover .anchor { 136 | visibility:visible; 137 | } 138 | 139 | /* Redfines headers style */ 140 | 141 | h2, h3, h4, h5, h6 { 142 | font-weight: 400; 143 | line-height: 1.1; 144 | } 145 | 146 | h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { 147 | font-weight: inherit; 148 | } 149 | 150 | h2 { 151 | font-size: 2.5rem; 152 | line-height: 110% !important; 153 | margin: 2.5rem 0 1.5rem 0; 154 | } 155 | 156 | h3 { 157 | font-size: 2rem; 158 | line-height: 110% !important; 159 | margin: 2rem 0 1rem 0; 160 | } 161 | 162 | h4 { 163 | font-size: 1.5rem; 164 | line-height: 110% !important; 165 | margin: 1.5rem 0 0.75rem 0; 166 | } 167 | 168 | h5 { 169 | font-size: 1rem; 170 | line-height: 110% !important; 171 | margin: 1rem 0 0.2rem 0; 172 | } 173 | 174 | h6 { 175 | font-size: 0.5rem; 176 | line-height: 110% !important; 177 | margin: 0.5rem 0 0.2rem 0; 178 | } 179 | 180 | p { 181 | margin: 1rem 0; 182 | } 183 | 184 | figcaption h4 { 185 | font-weight: 300 !important; 186 | opacity: .85; 187 | font-size: 1em; 188 | text-align: center; 189 | margin-top: -1.5em; 190 | } 191 | 192 | .select-style { 193 | border: 0; 194 | width: 150px; 195 | border-radius: 0px; 196 | overflow: hidden; 197 | display: inline-flex; 198 | } 199 | 200 | .select-style svg { 201 | fill: #ccc; 202 | width: 14px; 203 | height: 14px; 204 | pointer-events: none; 205 | margin: auto; 206 | } 207 | 208 | .select-style svg:hover { 209 | fill: #e6e6e6; 210 | } 211 | 212 | .select-style select { 213 | padding: 0; 214 | width: 130%; 215 | border: none; 216 | box-shadow: none; 217 | background: transparent; 218 | background-image: none; 219 | -webkit-appearance: none; 220 | margin: auto; 221 | margin-left: 0px; 222 | margin-right: -20px; 223 | } 224 | 225 | .select-style select:focus { 226 | outline: none; 227 | } 228 | 229 | .select-style :hover { 230 | cursor: pointer; 231 | } 232 | 233 | @media only all and (max-width: 47.938em) { 234 | #breadcrumbs .links, #top-github-link-text { 235 | display: none; 236 | } 237 | } 238 | 239 | .is-sticky #top-bar { 240 | box-shadow: -1px 2px 5px 1px rgba(0, 0, 0, 0.1); 241 | } -------------------------------------------------------------------------------- /src/commentator/handler.clj: -------------------------------------------------------------------------------- 1 | (ns commentator.handler 2 | "HTTP Handler component. 3 | This component is responsible from executing the actions binded to HTTP handlers." 4 | (:require [commentator.event :as ce] 5 | [commentator.challenge :as challenge] 6 | [commentator.comment :as cc] 7 | [commentator.usage :as usage] 8 | [corbihttp.log :as log] 9 | [commentator.rate-limit :as rate-limit] 10 | [exoscale.ex :as ex]) 11 | (:import java.util.UUID)) 12 | 13 | (defprotocol IHandler 14 | (new-comment [this request] "Creates a new comment") 15 | (get-comment [this request] "Get a specific comment") 16 | (comments-for-article [this request] "Get a comment for a specific article") 17 | (admin-for-article [this request] "Get a comment for a specific article as admin (list all)") 18 | (delete-comment [this request] "Delete a comment") 19 | (delete-article-comments [this request] "Delete all comments for an article") 20 | (approve-comment [this request] "Approve a comment") 21 | (random-challenge [this request] "get a random challenge") 22 | (list-events [this request] "List all events") 23 | (usage [this request] "Usage request") 24 | (get-usage-for-day [this request] "get usage for a given day") 25 | (delete-event [this request] "Delete a specific event") 26 | (healthz [this request] "Healthz endpoint") 27 | (metrics [this request] "Metric endpoint") 28 | (not-found [this request] "Not found response")) 29 | 30 | (defn req->article 31 | [request] 32 | (get-in request [:all-params :article])) 33 | 34 | (defn req->comment-id 35 | [request] 36 | (get-in request [:all-params :comment-id])) 37 | 38 | (defn req->website 39 | [request] 40 | (get-in request [:all-params :website])) 41 | 42 | (defn req->event-id 43 | [request] 44 | (get-in request [:all-params :id])) 45 | 46 | (defrecord Handler [comment-manager event-manager rate-limiter challenge-manager usage-component] 47 | IHandler 48 | (new-comment [_ request] 49 | (let [article (req->article request) 50 | params (:all-params request) 51 | comment (-> (merge (select-keys params [:content :author :author-website]) 52 | {:id (UUID/randomUUID) 53 | :approved false 54 | :timestamp (System/currentTimeMillis)}) 55 | cc/sanitize) 56 | answer (:answer params) 57 | timestamp (:timestamp params) 58 | signature (:signature params) 59 | website (:website params)] 60 | (ex/assert-spec-valid ::cc/comment comment) 61 | (challenge/verify challenge-manager {:answer answer 62 | :signature signature 63 | :timestamp timestamp 64 | :website website 65 | :article article}) 66 | (rate-limit/validate rate-limiter request website) 67 | (cc/add-comment comment-manager website article comment) 68 | (future (try (ce/add-event event-manager 69 | website 70 | (ce/new-comment website 71 | article 72 | (:id comment))) 73 | (catch Exception e 74 | (log/error (log/req-ctx request) 75 | e 76 | (format "fail to send event for new comment %s on article %s" 77 | (:id comment) 78 | article))))) 79 | {:status 201 80 | :body {:message "Comment added"}})) 81 | 82 | (get-comment [_ request] 83 | (let [article (req->article request) 84 | comment-id (req->comment-id request) 85 | website (req->website request)] 86 | {:status 200 87 | :body (cc/get-comment comment-manager website article comment-id)})) 88 | 89 | (comments-for-article [_ request] 90 | (let [article (req->article request) 91 | website (req->website request)] 92 | {:status 200 93 | :body (cc/for-article comment-manager website article)})) 94 | 95 | (admin-for-article [_ request] 96 | (let [article (req->article request) 97 | website (req->website request)] 98 | {:status 200 99 | :body (cc/for-article comment-manager website article true)})) 100 | 101 | (delete-comment [_ request] 102 | (let [article (req->article request) 103 | comment-id (req->comment-id request) 104 | website (req->website request)] 105 | (cc/delete-comment comment-manager website article comment-id) 106 | {:status 200 :body {:message "Comment deleted"}})) 107 | 108 | (delete-article-comments [_ request] 109 | (let [article (req->article request) 110 | website (req->website request)] 111 | (cc/delete-article comment-manager website article) 112 | {:status 200 :body {:message "Comments deleted"}})) 113 | 114 | (usage [_ request] 115 | (usage/new-request usage-component request) 116 | {:status 200 :body {:message "ok"}}) 117 | 118 | (approve-comment [_ request] 119 | (let [article (req->article request) 120 | comment-id (req->comment-id request) 121 | website (req->website request)] 122 | (cc/approve-comment comment-manager website article comment-id) 123 | {:status 200 :body {:message "Comment approved"}})) 124 | 125 | (random-challenge [_ request] 126 | (let [website (req->website request) 127 | article (req->article request)] 128 | {:status 200 129 | :body (challenge/random-challenge challenge-manager 130 | website article)})) 131 | 132 | (list-events [_ request] 133 | (let [website (req->website request)] 134 | {:status 200 135 | :body (ce/list-events event-manager website)})) 136 | 137 | (delete-event [_ request] 138 | (let [event-id (req->event-id request) 139 | website (req->website request)] 140 | (ce/delete-event event-manager website event-id) 141 | {:status 200 142 | :body {:message "Event deleted"}})) 143 | 144 | (get-usage-for-day [_ request] 145 | (let [day (get-in request [:all-params :day]) 146 | month (get-in request [:all-params :month]) 147 | year (get-in request [:all-params :year]) 148 | website (req->website request)] 149 | {:status 200 :body (usage/usage-for-day usage-component 150 | website 151 | year 152 | month 153 | day)})) 154 | 155 | ;; TODO 156 | (metrics [_ _] 157 | {:status 200 158 | :body ""}) 159 | 160 | (healthz [_ _] 161 | {:status 200 162 | :body {:message "ok"}}) 163 | 164 | (not-found [_ _] 165 | {:status 404 166 | :body {:error "not found"}})) 167 | -------------------------------------------------------------------------------- /site/commentator/public/js/modernizr.custom-3.6.0.js: -------------------------------------------------------------------------------- 1 | /*! modernizr 3.6.0 (Custom Build) | MIT * 2 | * https://modernizr.com/download/?-csstransforms3d-domprefixes-prefixes-setclasses-shiv-testallprops-testprop-teststyles !*/ 3 | !function(e,t,n){function r(e,t){return typeof e===t}function o(){var e,t,n,o,i,a,s;for(var l in S)if(S.hasOwnProperty(l)){if(e=[],t=S[l],t.name&&(e.push(t.name.toLowerCase()),t.options&&t.options.aliases&&t.options.aliases.length))for(n=0;nd;d++)if(h=e[d],g=P.style[h],a(h,"-")&&(h=l(h)),P.style[h]!==n){if(i||r(o,"undefined"))return u(),"pfx"==t?h:!0;try{P.style[h]=o}catch(y){}if(P.style[h]!=g)return u(),"pfx"==t?h:!0}return u(),!1}function h(e,t){return function(){return e.apply(t,arguments)}}function g(e,t,n){var o;for(var i in e)if(e[i]in t)return n===!1?e[i]:(o=t[e[i]],r(o,"function")?h(o,n||t):o);return!1}function v(e,t,n,o,i){var a=e.charAt(0).toUpperCase()+e.slice(1),s=(e+" "+T.join(a+" ")+a).split(" ");return r(t,"string")||r(t,"undefined")?m(s,t,o,i):(s=(e+" "+N.join(a+" ")+a).split(" "),g(s,t,n))}function y(e,t,r){return v(e,n,n,t,r)}var C=[],S=[],E={_version:"3.6.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,t){var n=this;setTimeout(function(){t(n[e])},0)},addTest:function(e,t,n){S.push({name:e,fn:t,options:n})},addAsyncTest:function(e){S.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=E,Modernizr=new Modernizr;var b=t.documentElement,x="svg"===b.nodeName.toLowerCase(),w=E._config.usePrefixes?" -webkit- -moz- -o- -ms- ".split(" "):["",""];E._prefixes=w;x||!function(e,t){function n(e,t){var n=e.createElement("p"),r=e.getElementsByTagName("head")[0]||e.documentElement;return n.innerHTML="x",r.insertBefore(n.lastChild,r.firstChild)}function r(){var e=C.elements;return"string"==typeof e?e.split(" "):e}function o(e,t){var n=C.elements;"string"!=typeof n&&(n=n.join(" ")),"string"!=typeof e&&(e=e.join(" ")),C.elements=n+" "+e,u(t)}function i(e){var t=y[e[g]];return t||(t={},v++,e[g]=v,y[v]=t),t}function a(e,n,r){if(n||(n=t),f)return n.createElement(e);r||(r=i(n));var o;return o=r.cache[e]?r.cache[e].cloneNode():h.test(e)?(r.cache[e]=r.createElem(e)).cloneNode():r.createElem(e),!o.canHaveChildren||m.test(e)||o.tagUrn?o:r.frag.appendChild(o)}function s(e,n){if(e||(e=t),f)return e.createDocumentFragment();n=n||i(e);for(var o=n.frag.cloneNode(),a=0,s=r(),l=s.length;l>a;a++)o.createElement(s[a]);return o}function l(e,t){t.cache||(t.cache={},t.createElem=e.createElement,t.createFrag=e.createDocumentFragment,t.frag=t.createFrag()),e.createElement=function(n){return C.shivMethods?a(n,e,t):t.createElem(n)},e.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+r().join().replace(/[\w\-:]+/g,function(e){return t.createElem(e),t.frag.createElement(e),'c("'+e+'")'})+");return n}")(C,t.frag)}function u(e){e||(e=t);var r=i(e);return!C.shivCSS||c||r.hasCSS||(r.hasCSS=!!n(e,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),f||l(e,r),e}var c,f,d="3.7.3",p=e.html5||{},m=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,h=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,g="_html5shiv",v=0,y={};!function(){try{var e=t.createElement("a");e.innerHTML="",c="hidden"in e,f=1==e.childNodes.length||function(){t.createElement("a");var e=t.createDocumentFragment();return"undefined"==typeof e.cloneNode||"undefined"==typeof e.createDocumentFragment||"undefined"==typeof e.createElement}()}catch(n){c=!0,f=!0}}();var C={elements:p.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:d,shivCSS:p.shivCSS!==!1,supportsUnknownElements:f,shivMethods:p.shivMethods!==!1,type:"default",shivDocument:u,createElement:a,createDocumentFragment:s,addElements:o};e.html5=C,u(t),"object"==typeof module&&module.exports&&(module.exports=C)}("undefined"!=typeof e?e:this,t);var _="Moz O ms Webkit",N=E._config.usePrefixes?_.toLowerCase().split(" "):[];E._domPrefixes=N;var z=(E.testStyles=c,{elem:s("modernizr")});Modernizr._q.push(function(){delete z.elem});var P={style:z.elem.style};Modernizr._q.unshift(function(){delete P.style});var j=(E.testProp=function(e,t,r){return m([e],n,t,r)},"CSS"in e&&"supports"in e.CSS),k="supportsCSS"in e;Modernizr.addTest("supports",j||k);var T=E._config.usePrefixes?_.split(" "):[];E._cssomPrefixes=T,E.testAllProps=v,E.testAllProps=y,Modernizr.addTest("csstransforms3d",function(){return!!y("perspective","1px",!0)}),o(),i(C),delete E.addTest,delete E.addAsyncTest;for(var F=0;F https://github.com/noelboss/featherlight/issues/317 9 | !function(u){"use strict";if(void 0!==u)if(u.fn.jquery.match(/-ajax/))"console"in window&&window.console.info("Featherlight needs regular jQuery, not the slim version.");else{var r=[],i=function(t){return r=u.grep(r,function(e){return e!==t&&0','
','",'
'+n.loading+"
","
",""].join("")),o="."+n.namespace+"-close"+(n.otherClose?","+n.otherClose:"");return n.$instance=i.clone().addClass(n.variant),n.$instance.on(n.closeTrigger+"."+n.namespace,function(e){if(!e.isDefaultPrevented()){var t=u(e.target);("background"===n.closeOnClick&&t.is("."+n.namespace)||"anywhere"===n.closeOnClick||t.closest(o).length)&&(n.close(e),e.preventDefault())}}),this},getContent:function(){if(!1!==this.persist&&this.$content)return this.$content;var t=this,e=this.constructor.contentFilters,n=function(e){return t.$currentTarget&&t.$currentTarget.attr(e)},r=n(t.targetAttr),i=t.target||r||"",o=e[t.type];if(!o&&i in e&&(o=e[i],i=t.target&&r),i=i||n("href")||"",!o)for(var a in e)t[a]&&(o=e[a],i=t[a]);if(!o){var s=i;if(i=null,u.each(t.contentFilters,function(){return(o=e[this]).test&&(i=o.test(s)),!i&&o.regex&&s.match&&s.match(o.regex)&&(i=s),!i}),!i)return"console"in window&&window.console.error("Featherlight: no content filter found "+(s?' for "'+s+'"':" (no target specified)")),!1}return o.process.call(t,i)},setContent:function(e){return this.$instance.removeClass(this.namespace+"-loading"),this.$instance.toggleClass(this.namespace+"-iframe",e.is("iframe")),this.$instance.find("."+this.namespace+"-inner").not(e).slice(1).remove().end().replaceWith(u.contains(this.$instance[0],e[0])?"":e),this.$content=e.addClass(this.namespace+"-inner"),this},open:function(t){var n=this;if(n.$instance.hide().appendTo(n.root),!(t&&t.isDefaultPrevented()||!1===n.beforeOpen(t))){t&&t.preventDefault();var e=n.getContent();if(e)return r.push(n),s(!0),n.$instance.fadeIn(n.openSpeed),n.beforeContent(t),u.when(e).always(function(e){n.setContent(e),n.afterContent(t)}).then(n.$instance.promise()).done(function(){n.afterOpen(t)})}return n.$instance.detach(),u.Deferred().reject().promise()},close:function(e){var t=this,n=u.Deferred();return!1===t.beforeClose(e)?n.reject():(0===i(t).length&&s(!1),t.$instance.fadeOut(t.closeSpeed,function(){t.$instance.detach(),t.afterClose(e),n.resolve()})),n.promise()},resize:function(e,t){if(e&&t&&(this.$content.css("width","").css("height",""),this.$content.parent().width()');return n.onload=function(){r.naturalWidth=n.width,r.naturalHeight=n.height,t.resolve(r)},n.onerror=function(){t.reject(r)},n.src=e,t.promise()}},html:{regex:/^\s*<[\w!][^<]*>/,process:function(e){return u(e)}},ajax:{regex:/./,process:function(e){var n=u.Deferred(),r=u("
").load(e,function(e,t){"error"!==t&&n.resolve(r.contents()),n.fail()});return n.promise()}},iframe:{process:function(e){var t=new u.Deferred,n=u("