├── .dockerignore
├── .gitignore
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── README.md
├── build
├── client.js
└── embed.js
├── create-schnack
├── LICENSE
├── index.js
├── package-lock.json
├── package.json
├── readme.md
└── setup.js
├── docs
└── migrating-to-schnack-1.md
├── index.js
├── migrations
├── 001-initial-schema.sql
├── 002-notifications.sql
├── 003-replies.sql
├── 004-indices.sql
├── 005-unique-provider_id.sql
├── 006-user-url.sql
└── 007-oauth-providers.sql
├── package-lock.json
├── package.json
├── rollup.config.js
├── schnack.tpl.json
├── src
├── auth.js
├── config.js
├── db
│ ├── index.js
│ └── queries.js
├── embed
│ ├── client.js
│ ├── comments.jst.html
│ ├── index.js
│ ├── push.js
│ └── schnack.jst.html
├── events.js
├── helper.js
├── importer.js
├── notify.js
├── plugins.js
├── plugins
│ └── notify-webpush
│ │ └── index.js
└── server.js
├── sw.js
└── test
├── disqus.xml
├── fonts
├── schnack.eot
├── schnack.svg
├── schnack.ttf
├── schnack.woff
└── schnack.woff2
├── index.html
├── schnack-icons.css
└── schnack.css
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | yarn.lock
3 | *.db
4 | config.json
5 | schnack.json
6 | npm-debug.log
7 | *.sublime-*
8 | .DS_Store
9 | certs/*
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [1.2.0](https://github.com/gka/schnack/compare/v1.1.0...v1.2.0) (2021-01-16)
2 |
3 | ### Features
4 |
5 | * simplified local development with `npm run dev` ([c0414f5](https://github.com/gka/schnack/commit/c0414f59aac1145fd3ebaa0a6df5e83077c30d10))
6 |
7 | ## [1.1.0](https://github.com/gka/schnack/compare/v0.2.3...v1.1.0) (2021-01-16)
8 |
9 | * add https support for testing on localhost (thx @danmenzies-jerram) ([9d90030](https://github.com/gka/schnack/commit/9d90030))
10 | * simple plugin system for schnack (#99) ([9c31858](https://github.com/gka/schnack/commit/9c31858)), closes [#99](https://github.com/gka/schnack/issues/99)
11 |
12 | ## [0.2.3](https://github.com/gka/schnack/compare/v0.2.2...v0.2.3) (2020-05-24)
13 |
14 | * deprecated google api replacement ([6eb2eca](https://github.com/gka/schnack/commit/6eb2eca))
15 |
16 | ## [0.2.2](https://github.com/gka/schnack/compare/v0.1.4...v0.2.2) (2019-03-05)
17 |
18 | ### Features
19 |
20 | * **auth:** add facebook oauth ([313f249](https://github.com/gka/schnack/commit/313f249298babfcb9d990957a80689daa54dcb99)), closes [#18](https://github.com/gka/schnack/issues/18)
21 | * **auth:** add google oauth ([0a86479](https://github.com/gka/schnack/commit/0a8647968e70f176e85ce9f63fc287df4908962c)), closes [#17](https://github.com/gka/schnack/issues/17)
22 | * **docs:** add generator ([7269d73](https://github.com/gka/schnack/commit/7269d73b757c12e0700d9bc047fee70f066ac6b3))
23 | * **font:** self host font ([7ee1295](https://github.com/gka/schnack/commit/7ee1295ab6c0f1578fbf2f8566e59a5e5bc60001))
24 | * **importer:** add Wordpress importer ([0f929fb](https://github.com/gka/schnack/commit/0f929fbff9f35f59c0dbda8565298abccafe94d5)), closes [#13](https://github.com/gka/schnack/issues/13)
25 | * **initialization:** create schnack client.js ([4691c7f](https://github.com/gka/schnack/commit/4691c7f13e7fc5d0f149e55307c815369dcd8944))
26 | * **notification:** add support for sendmail notification provider + node v10 support ([2e629f5](https://github.com/gka/schnack/commit/2e629f5c85d48f2b8f4d3a1c22bfd9cf4ac01b6d))
27 | * **notification-url:** Send page URL with notification instead of just slug ([#82](https://github.com/gka/schnack/issues/82)) ([4e5ffae](https://github.com/gka/schnack/commit/4e5ffae5573ec6d7c8478f3fff11a302ee54b0b0))
28 |
29 |
30 | ### Bug Fixes
31 |
32 | * **auth:** handle redirect server-side ([30881d8](https://github.com/gka/schnack/commit/30881d88c8747112b3c238c9f2ac86eed2813dd6))
33 | * **build:** minimize and remove log statements ([2e0bf24](https://github.com/gka/schnack/commit/2e0bf24f07839f084550a33c032e21a706f6cbf3))
34 | * **client.js:** set document domain ([cfdf1cb](https://github.com/gka/schnack/commit/cfdf1cb98989fe420fe53f88ac11bcb1e4648757))
35 | * **CORS:** allow to run schnack server and client on localhost ([e724da9](https://github.com/gka/schnack/commit/e724da9f9d8c14401417737801e00b3918295efa))
36 | * **docs:** class instead of id ([ada6104](https://github.com/gka/schnack/commit/ada610441f44cb17f06577be6088c8250f5ebb35))
37 | * **docs:** typo ([9278e30](https://github.com/gka/schnack/commit/9278e308735887ddb42bcd9d1f628bd7058a7d62))
38 | * **migrations:** add unique index on user(provider,provider_id) ([9e36d01](https://github.com/gka/schnack/commit/9e36d0178447397e197c969fc7e03b95098d46c9))
39 | * **pkg:** update marked ([9279191](https://github.com/gka/schnack/commit/92791915f1ec9f9fff2565aeecacfe83653d331b))
40 | * **rollup:** use uglify again ([a8d8aec](https://github.com/gka/schnack/commit/a8d8aece7db5718f1f548c8be7d8d988edd02098))
41 | * **routes:** serve client.js ([59b1f87](https://github.com/gka/schnack/commit/59b1f87e5491630b59dc9c25b5a8a1047fa7462b))
42 | * **RSS:** use temporary site_url ([453d33d](https://github.com/gka/schnack/commit/453d33dcfc450b01543ed1ffccb449740934af87)), closes [#74](https://github.com/gka/schnack/issues/74)
43 | * **slack:** add try/catch ([eedb6c5](https://github.com/gka/schnack/commit/eedb6c59892b3400dca74f71b812c68f754be70c))
44 | * **typo:** fix typo in embedded scripts ([76fd6c0](https://github.com/gka/schnack/commit/76fd6c02db9c4982218fa0abb3ee2f267459cab9)), closes [#69](https://github.com/gka/schnack/issues/69)
45 |
46 | ### [0.1.4](https://github.com/gka/schnack/compare/0.1.3...v0.1.4) (2017-12-21)
47 |
48 |
49 | ### Features
50 |
51 | * **containers:** add Dockerfile ([517df75](https://github.com/gka/schnack/commit/517df75b3e3844486be736a2cefc2d5324212fe0)), closes [#28](https://github.com/gka/schnack/issues/28)
52 | * **importer:** add disqus importer ([5f04ae7](https://github.com/gka/schnack/commit/5f04ae7cd9f85468423a144fe408b86402d395f6))
53 |
54 |
55 | ### Bug Fixes
56 |
57 | * url.host != url.hostname ([bd29962](https://github.com/gka/schnack/commit/bd299624793fb8a80adf38eb7dbbacf904fc6a2c))
58 | * **importer:** add npm script ([0171f88](https://github.com/gka/schnack/commit/0171f885723294314c136c1f41c7b0f1093cff23))
59 | * **tmpl:** show reply button on login ([6b2e63e](https://github.com/gka/schnack/commit/6b2e63e4e7a745e28531c809c7a319876a1c0c20))
60 | * **tmpl:** wrap login status in div element ([baa6c2f](https://github.com/gka/schnack/commit/baa6c2f8379dbb53b9df4fbcd09b35396ea126d8))
61 |
62 | ### [0.1.3](https://github.com/gka/schnack/compare/eddf0948051bcad998fb0f0cb0ff82c7daeaa0dd...0.1.3) (2017-10-25)
63 |
64 |
65 | ### Features
66 |
67 | * **auth:** starting integrating Twitter OAuth ([f560ab5](https://github.com/gka/schnack/commit/f560ab50d6def7f65b34b5a91f19ca694f73fa47))
68 | * **migrations:** use sqlite instead of sqlite3 to use migrations ([411545a](https://github.com/gka/schnack/commit/411545a72dd19299aa7a620d5d7703d0645e0dc1))
69 |
70 |
71 | ### Bug Fixes
72 |
73 | * **CORS:** add whitelist for trusted domains ([eddf094](https://github.com/gka/schnack/commit/eddf0948051bcad998fb0f0cb0ff82c7daeaa0dd))
74 | * **CORS:** use allow_origin config key instead of cors object ([3bd5aac](https://github.com/gka/schnack/commit/3bd5aac5928ee0afd090e2cdae8b6b8b2de286dd))
75 | * **drafts:** clear textarea on post ([87550e4](https://github.com/gka/schnack/commit/87550e41b35065b0c9ec10d807cfc1d70f67bf5f))
76 | * **drafts:** load draft only if textarea is present ([1db4180](https://github.com/gka/schnack/commit/1db4180ee34d4a655eb476ba0bfbefc17762448b))
77 | * **migrations:** rename migration file to notifications ([795dbbe](https://github.com/gka/schnack/commit/795dbbe2a260ebeb6c911916e78d295834bd201d))
78 | * **push:** use pushover-notifications instead of node-pushover ([01e738e](https://github.com/gka/schnack/commit/01e738e26e41f60106f6f74d5955b04cd5d6f161)), closes [#3](https://github.com/gka/schnack/issues/3)
79 | * **require:** fs was used but not defined ([4dab6d2](https://github.com/gka/schnack/commit/4dab6d22ca2db9bdacf5f4d18ffa43d03a93cfaf))
80 |
81 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:boron
2 |
3 | WORKDIR /usr/src/app
4 | COPY package.json package-lock.json ./
5 | RUN npm install
6 |
7 | COPY . .
8 |
9 | EXPOSE 3000
10 | CMD [ "npm", "run", "server" ]
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The Lil License v1
2 |
3 | Copyright (c) 2021 Gregor Aisch, Moritz Klack & g-div
4 |
5 | Permission is hereby granted by the authors of this software, to any person,
6 | to use the software for any purpose, free of charge, including the rights to
7 | run, read, copy, change, distribute and sell it, and including usage rights to
8 | any patents the authors may hold on it, subject to the following conditions:
9 |
10 | This license, or a link to its text, must be included with all copies of the
11 | software and any derivative works.
12 |
13 | Any modification to the software submitted to the authors may be incorporated
14 | into the software under the terms of this license.
15 |
16 | The software is provided "as is", without warranty of any kind, including but
17 | not limited to the warranties of title, fitness, merchantability and non-
18 | infringement. The authors have no obligation to provide support or updates for
19 | the software, and may not be held liable for any damages, claims or other
20 | liability arising from its use.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # schnack.js
2 |
3 | [Schnack](https://dict.leo.org/englisch-deutsch/schnack) is a simple Disqus-like drop-in commenting system written in JavaScript.
4 |
5 | - [Documentation](https://schnack.cool/)
6 | - [Say hello to Schnack.js](https://www.vis4.net/blog/2017/10/hello-schnack/)
7 | - Follow [@schnackjs](https://twitter.com/schnackjs) on Twitter
8 |
9 | ## What the schnack?
10 |
11 | Features:
12 |
13 | - Tiny! It takes only ~**8 KB!!!** to embed Schnack.
14 | - **Open source** and **self-hosted**.
15 | - Ad-free and Tracking-free. Schnack will **not disturb your users**.
16 | - It's simpy to moderate, with a **minimal** and **slick UI** to allow/reject comments or trust/block users.
17 | - **[webpush protocol](https://tools.ietf.org/html/draft-ietf-webpush-protocol-12) to notify the site owner** about new comments awaiting for moderation.
18 | - **Third party providers for authentication** like Github, Twitter, Google and Facebook. Users are not required to register a new account on your system and you don't need to manage a user management system.
19 |
20 | ### Quickstart
21 |
22 | *Note: If you are updating Schnack from a 0.x version check out the separate [upgrade instructions](docs/migrating-to-schnack-1.md).*
23 |
24 | This is the fastest way to setup _schnack_.
25 |
26 | **Requirements**:
27 |
28 | - Node.js (>= v8)
29 | - npm (>= v6)
30 |
31 | Create a new folder for schnack and change into it:
32 |
33 | ```bash
34 | mkdir schnack
35 | cd schnack
36 | npm init schnack
37 | ```
38 |
39 | if there is no `schnack.json` in this folder, the init script copied over the default config and ask you if you want to configure your server interactively.
40 |
41 | alternatively you can just edit the config file according to [configuration](https://schnack.cool/#configuration) section:
42 |
43 | ```bash
44 | vim schnack.json # or open with any editor of your choice
45 | ```
46 |
47 | Finally, run `npm init schnack` again to finish installation:
48 |
49 | ```bash
50 | npm init schnack
51 | ```
52 |
53 | Run the server:
54 |
55 | ```bash
56 | npm start
57 | ```
58 |
59 | If you want to try out Schnack on localhost (without authentication), run
60 |
61 | ```bash
62 | npm start -- --dev
63 | ```
64 |
65 | Embed in your HTML page:
66 |
67 | ```html
68 |
69 |
74 | ```
75 |
76 | **or** initialize _schnack_ programmatically:
77 |
78 | ```html
79 |
80 |
81 |
82 |
89 | ```
90 |
91 | You will find further information on the [schnack page](https://schnack.cool/).
92 |
93 | ### Plugins
94 |
95 | Authentication and notification providers can be added via plugins.
96 |
97 | ```sh
98 | npm install @schnack/plugin-auth-github @schnack/plugin-auth-google @schnack/plugin-notify-slack
99 | ```
100 |
101 | To enable the plugins you need to add them to the `plugins` section of your `schnack.json`:
102 |
103 | ```js
104 | {
105 | // ...
106 | "plugins": {
107 | "auth-github": {
108 | "client_id": "xxxxx",
109 | "client_secret": "xxxxx"
110 | },
111 | "auth-google": {
112 | "client_id": "xxxxx",
113 | "client_secret": "xxxxx"
114 | },
115 | "notify-slack": {
116 | "webhook_url": "xxxxx"
117 | }
118 | }
119 | }
120 | ```
121 |
122 | if you want to write your own plugins you need to install them and specify their package name in the `schnack.json`. Otherwise Schnack would try to load as from `@schnack/plugin-my-plugin`.
123 |
124 | ```js
125 | {
126 | // ...
127 | "plugins": {
128 | "my-plugin": {
129 | "pkg": "my-schnack-plugin",
130 | // ...
131 | }
132 | }
133 | }
134 | ```
135 |
136 | Feel free to open a PR on [schnack-plugins](https://github.com/schn4ck/schnack-plugins) with your plugin if you want to add it to the "official" repository.
137 |
138 | ### Who is behind Schnack?
139 |
140 | Schnack is [yet another](https://github.com/gka/canvid/) happy collaboration between [Webkid](https://webkid.io/) and [Gregor Aisch](https://www.vis4.net), with amazing contributions from:
141 |
142 | * [Jerram Digital](https://jerram.co.uk/)
143 | * [Levi Wheatcroft](https://github.com/leviwheatcroft)
144 |
145 | ### Who is using Schnack?
146 |
147 | Schnack will never track who is using it, so we don't know! If you are a Schnack user, [let us know](https://twitter.com/schnackjs) and we'll add your website here. So far Schnack is being used on:
148 |
149 | - https://schnack.cool (scroll all the day down)
150 | - https://vis4.net/blog
151 | - https://blog.datawrapper.de
152 | - https://blog.webkid.io
153 |
154 | ### Related projects
155 |
156 | This is not a new idea, so there are a few projects that are doing almost the same thing:
157 |
158 | - [CoralProject Talk](https://github.com/coralproject/talk) - Node + MongoDB + Redis
159 | - [Discourse](https://github.com/discourse/discourse) - Ruby on Rails + PostgreSQL + Redis
160 | - [Commento](https://github.com/adtac/commento) - Go + Node
161 | - [Isso](https://github.com/posativ/isso/) - Python + SQLite3
162 | - [Mouthful](https://mouthful.dizzy.zone) – Go + Preact
163 |
164 | ### Developer notes
165 |
166 | If you want to run your Schnack server on https on localhost, add the following section to your `schnack.json`:
167 |
168 | ```js
169 | {
170 | "ssl": {
171 | "certificate_path": "./certs/local.crt",
172 | "certificate_key": "./certs/local.key"
173 | }
174 | }
175 | ```
176 |
177 | To test changes on the `embed.js` and `client.js` templates you can open a local test server with minimal styles and by-passed authentication using
178 |
179 | ```bash
180 | npm run dev
181 | ```
182 |
183 | We're veIf you want to contribute additional **plugins**, check out the source code for the existing plugins first. We happily accept pull requests on [schnack-plugins](https://github.com/schn4ck/schnack-plugins).
184 |
185 | This project used [Conventional Commits](https://www.conventionalcommits.org/).
--------------------------------------------------------------------------------
/build/client.js:
--------------------------------------------------------------------------------
1 | !function(n,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(n="undefined"!=typeof globalThis?globalThis:n||self).Schnack=t()}(this,(function(){"use strict";function n(n,t){return t=t||{},new Promise((function(e,a){var s=new XMLHttpRequest,o=[],i=[],c={},l=function(){return{ok:2==(s.status/100|0),statusText:s.statusText,status:s.status,url:s.responseURL,text:function(){return Promise.resolve(s.responseText)},json:function(){return Promise.resolve(s.responseText).then(JSON.parse)},blob:function(){return Promise.resolve(new Blob([s.response]))},clone:l,headers:{keys:function(){return o},entries:function(){return i},get:function(n){return c[n.toLowerCase()]},has:function(n){return n.toLowerCase()in c}}}};for(var r in s.open(t.method||"get",n,!0),s.onload=function(){s.getAllResponseHeaders().replace(/^(.*?):[^\S\n]*([\s\S]*?)$/gm,(function(n,t,e){o.push(t=t.toLowerCase()),i.push([t,e]),c[t]=c[t]?c[t]+","+e:e})),e(l())},s.onerror=a,s.withCredentials="include"==t.credentials,t.headers)s.setRequestHeader(r,t.headers[r]);s.send(t.body||null)}))}function t(n){var t,e="";return e+='\n"}var e=function(n){return document.querySelector(n)},a=function(n){this.options=n,this.options.endpoint=n.host+"/comments/"+n.slug,this.initialized=!1,this.firstLoad=!0;var t=new URL(n.host);"localhost"!==t.hostname&&(document.domain=t.hostname.split(".").slice(1).join(".")),this.refresh()};return a.prototype.refresh=function(){var a=this,s=this.options,o=s.target,i=s.slug,c=s.host,l=s.endpoint,r=s.partials;n(l,{credentials:"include",headers:{"Content-Type":"application/json"}}).then((function(n){return n.json()})).then((function(s){s.comments_tpl=t,s.partials=r,e(o).innerHTML=function(n){var t,e="";n.user?(e+="\n ",n.user.admin&&(e+='\n \n '+(null==(t=n.partials.UnMute)?"":t)+' \n '+(null==(t=n.partials.Mute)?"":t)+" \n
\n "),e+='\n\n '+(null==(t=n.partials.LoginStatus.replace("%USER%",n.user.name))?"":t)+'\n
\n\n"):(e+='\n\n',n.auth.length?(e+="\n"+(null==(t=n.partials.SignInVia)?"":t)+" \n",n.auth.forEach((function(a,s){e+="\n "+(null==(t=s?n.partials.Or:"")?"":t)+' '+(null==(t=a.name)?"":t)+" \n"})),e+="\n"):e+="\n"+(null==(t=n.partials.NoAuthProviders)?"":t)+"\n",e+="\n"),e+="\n
\n";var a=[];return n.replies={},n.comments.forEach((function(t){t.reply_to?(n.replies[t.reply_to]||(n.replies[t.reply_to]=[]),n.replies[t.reply_to].push(t)):a.push(t)})),n.comments=a,e+="\n"+(null==(t=n.comments_tpl(n))?"":t)+'\n\n'}(s);var u=e(o+" div.schnack-above"),d=e(o+" div.schnack-form"),h=e(o+" textarea.schnack-body"),p=e(o+" .schnack-form blockquote.schnack-body"),f=window.localStorage.getItem("schnack-draft-"+i);f&&h&&(h.value=f);var m,y=e(o+" .schnack-button"),v=e(o+" .schnack-preview"),k=e(o+" .schnack-write"),b=e(o+" .schnack-cancel-reply"),g=(m=o+" .schnack-reply",document.querySelectorAll(m));if(y&&(y.addEventListener("click",(function(t){var e=h.value;n(l,{credentials:"include",method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({comment:e,replyTo:d.dataset.reply})}).then((function(n){return n.json()})).then((function(n){h.value="",window.localStorage.setItem("schnack-draft-"+i,h.value),n.id&&(a.firstLoad=!0,window.location.hash="#comment-"+n.id),a.refresh()}))})),v.addEventListener("click",(function(t){var e=h.value;h.style.display="none",v.style.display="none",p.style.display="block",k.style.display="inline",n(c+"/markdown",{credentials:"include",method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({comment:e})}).then((function(n){return n.json()})).then((function(n){p.innerHTML=n.html}))})),k.addEventListener("click",(function(n){h.style.display="inline",v.style.display="inline",p.style.display="none",k.style.display="none"})),h.addEventListener("keyup",(function(){window.localStorage.setItem("schnack-draft-"+i,h.value)})),g.forEach((function(n){n.addEventListener("click",(function(){d.dataset.reply=n.dataset.replyTo,b.style.display="inline-block",n.parentElement.appendChild(d)}))})),b.addEventListener("click",(function(){u.appendChild(d),delete d.dataset.reply,b.style.display="none"}))),s.user){var w=e("a.schnack-signout");w&&w.addEventListener("click",(function(t){t.preventDefault(),n(c+"/signout",{credentials:"include",headers:{"Content-Type":"application/json"}}).then((function(){return a.refresh()}))}))}else s.auth.forEach((function(t){var s=e(o+" .schnack-signin-"+t.id);s&&s.addEventListener("click",(function(e){var s=function(n){void 0===n&&(n="");var e=window.open(c+"/auth/"+t.id+(n?"/d/"+n:""),t.name+" Sign-In","resizable,scrollbars,status,width=600,height=500");window.__schnack_wait_for_oauth=function(){e.close(),a.refresh()}};if("mastodon"===t.id){var o=window.prompt("Please enter the domain name of the Mastodon instance you want to sign in with:","mastodon.social");n("https://"+o+"/api/v1/instance").then((function(n){return n.json()})).then((function(n){n.uri===o?s(o):window.alert('We could not find a Mastodon instance at "'+o+'". Please try again.')})).catch((function(n){console.error(n),window.alert('We could not find a Mastodon instance at "'+o+'". Please try again.')}))}else s()}))}));if(s.user&&s.user.admin){if(!a.initialized){var L=document.createElement("script");L.setAttribute("src",c+"/push.js"),document.head.appendChild(L),a.initialized=!0}var E=function(t){var e=t.target.dataset;n(c+"/"+e.class+"/"+e.target+"/"+e.action,{credentials:"include",method:"POST",headers:{"Content-Type":"application/json"},body:""}).then((function(){return a.refresh()}))};document.querySelectorAll(".schnack-action").forEach((function(n){n.addEventListener("click",E)}))}if(a.firstLoad&&window.location.hash.match(/^#comment-\d+$/)){var S=document.querySelector(window.location.hash);S.scrollIntoView(),S.classList.add("schnack-highlight"),a.firstLoad=!1}}))},a}));
2 |
--------------------------------------------------------------------------------
/build/embed.js:
--------------------------------------------------------------------------------
1 | !function(){"use strict";function n(n,t){return t=t||{},new Promise((function(e,a){var s=new XMLHttpRequest,i=[],o=[],c={},l=function(){return{ok:2==(s.status/100|0),statusText:s.statusText,status:s.status,url:s.responseURL,text:function(){return Promise.resolve(s.responseText)},json:function(){return Promise.resolve(s.responseText).then(JSON.parse)},blob:function(){return Promise.resolve(new Blob([s.response]))},clone:l,headers:{keys:function(){return i},entries:function(){return o},get:function(n){return c[n.toLowerCase()]},has:function(n){return n.toLowerCase()in c}}}};for(var r in s.open(t.method||"get",n,!0),s.onload=function(){s.getAllResponseHeaders().replace(/^(.*?):[^\S\n]*([\s\S]*?)$/gm,(function(n,t,e){i.push(t=t.toLowerCase()),o.push([t,e]),c[t]=c[t]?c[t]+","+e:e})),e(l())},s.onerror=a,s.withCredentials="include"==t.credentials,t.headers)s.setRequestHeader(r,t.headers[r]);s.send(t.body||null)}))}function t(n){var t,e="";return e+='\n"}var e=function(n){return document.querySelector(n)},a=function(n){this.options=n,this.options.endpoint=n.host+"/comments/"+n.slug,this.initialized=!1,this.firstLoad=!0;var t=new URL(n.host);"localhost"!==t.hostname&&(document.domain=t.hostname.split(".").slice(1).join(".")),this.refresh()};a.prototype.refresh=function(){var a=this,s=this.options,i=s.target,o=s.slug,c=s.host,l=s.endpoint,r=s.partials;n(l,{credentials:"include",headers:{"Content-Type":"application/json"}}).then((function(n){return n.json()})).then((function(s){s.comments_tpl=t,s.partials=r,e(i).innerHTML=function(n){var t,e="";n.user?(e+="\n ",n.user.admin&&(e+='\n \n '+(null==(t=n.partials.UnMute)?"":t)+' \n '+(null==(t=n.partials.Mute)?"":t)+" \n
\n "),e+='\n\n '+(null==(t=n.partials.LoginStatus.replace("%USER%",n.user.name))?"":t)+'\n
\n\n"):(e+='\n\n',n.auth.length?(e+="\n"+(null==(t=n.partials.SignInVia)?"":t)+" \n",n.auth.forEach((function(a,s){e+="\n "+(null==(t=s?n.partials.Or:"")?"":t)+' '+(null==(t=a.name)?"":t)+" \n"})),e+="\n"):e+="\n"+(null==(t=n.partials.NoAuthProviders)?"":t)+"\n",e+="\n"),e+="\n
\n";var a=[];return n.replies={},n.comments.forEach((function(t){t.reply_to?(n.replies[t.reply_to]||(n.replies[t.reply_to]=[]),n.replies[t.reply_to].push(t)):a.push(t)})),n.comments=a,e+="\n"+(null==(t=n.comments_tpl(n))?"":t)+'\n\n'}(s);var u=e(i+" div.schnack-above"),d=e(i+" div.schnack-form"),h=e(i+" textarea.schnack-body"),p=e(i+" .schnack-form blockquote.schnack-body"),m=window.localStorage.getItem("schnack-draft-"+o);m&&h&&(h.value=m);var f,v=e(i+" .schnack-button"),k=e(i+" .schnack-preview"),y=e(i+" .schnack-write"),g=e(i+" .schnack-cancel-reply"),b=(f=i+" .schnack-reply",document.querySelectorAll(f));if(v&&(v.addEventListener("click",(function(t){var e=h.value;n(l,{credentials:"include",method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({comment:e,replyTo:d.dataset.reply})}).then((function(n){return n.json()})).then((function(n){h.value="",window.localStorage.setItem("schnack-draft-"+o,h.value),n.id&&(a.firstLoad=!0,window.location.hash="#comment-"+n.id),a.refresh()}))})),k.addEventListener("click",(function(t){var e=h.value;h.style.display="none",k.style.display="none",p.style.display="block",y.style.display="inline",n(c+"/markdown",{credentials:"include",method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({comment:e})}).then((function(n){return n.json()})).then((function(n){p.innerHTML=n.html}))})),y.addEventListener("click",(function(n){h.style.display="inline",k.style.display="inline",p.style.display="none",y.style.display="none"})),h.addEventListener("keyup",(function(){window.localStorage.setItem("schnack-draft-"+o,h.value)})),b.forEach((function(n){n.addEventListener("click",(function(){d.dataset.reply=n.dataset.replyTo,g.style.display="inline-block",n.parentElement.appendChild(d)}))})),g.addEventListener("click",(function(){u.appendChild(d),delete d.dataset.reply,g.style.display="none"}))),s.user){var w=e("a.schnack-signout");w&&w.addEventListener("click",(function(t){t.preventDefault(),n(c+"/signout",{credentials:"include",headers:{"Content-Type":"application/json"}}).then((function(){return a.refresh()}))}))}else s.auth.forEach((function(t){var s=e(i+" .schnack-signin-"+t.id);s&&s.addEventListener("click",(function(e){var s=function(n){void 0===n&&(n="");var e=window.open(c+"/auth/"+t.id+(n?"/d/"+n:""),t.name+" Sign-In","resizable,scrollbars,status,width=600,height=500");window.__schnack_wait_for_oauth=function(){e.close(),a.refresh()}};if("mastodon"===t.id){var i=window.prompt("Please enter the domain name of the Mastodon instance you want to sign in with:","mastodon.social");n("https://"+i+"/api/v1/instance").then((function(n){return n.json()})).then((function(n){n.uri===i?s(i):window.alert('We could not find a Mastodon instance at "'+i+'". Please try again.')})).catch((function(n){console.error(n),window.alert('We could not find a Mastodon instance at "'+i+'". Please try again.')}))}else s()}))}));if(s.user&&s.user.admin){if(!a.initialized){var S=document.createElement("script");S.setAttribute("src",c+"/push.js"),document.head.appendChild(S),a.initialized=!0}var E=function(t){var e=t.target.dataset;n(c+"/"+e.class+"/"+e.target+"/"+e.action,{credentials:"include",method:"POST",headers:{"Content-Type":"application/json"},body:""}).then((function(){return a.refresh()}))};document.querySelectorAll(".schnack-action").forEach((function(n){n.addEventListener("click",E)}))}if(a.firstLoad&&window.location.hash.match(/^#comment-\d+$/)){var L=document.querySelector(window.location.hash);L.scrollIntoView(),L.classList.add("schnack-highlight"),a.firstLoad=!1}}))},function(){var n=document.querySelector("script[data-schnack-target]");if(!n)return console.warn("schnack script tag needs some data attributes");var t=n.dataset,e=t.schnackSlug,s=new URL(n.getAttribute("src")),i=s.protocol+"//"+s.host,o={Preview:"Preview",Edit:"Edit",SendComment:"Send comment",Cancel:"Cancel",Or:"Or",Mute:"mute notifications",UnMute:"unmute",PostComment:"Post a comment. Markdown is supported!",AdminApproval:"This comment is still waiting for your approval",WaitingForApproval:"Your comment is still waiting for approval by the site owner",SignInVia:"To post a comment you need to sign in via",Reply:" reply",LoginStatus:"(signed in as @%USER% :: sign out )",NoAuthProviders:"You haven't configured any auth providers, yet."};Object.keys(o).forEach((function(t){n.dataset["schnackPartial"+t]&&(o[t]=n.dataset["schnackPartial"+t])})),new a({target:t.schnackTarget,slug:e,host:i,partials:o})}()}();
2 |
--------------------------------------------------------------------------------
/create-schnack/LICENSE:
--------------------------------------------------------------------------------
1 | The Lil License v1
2 |
3 | Copyright (c) 2019 Gregor Aisch, Moritz Klack & g-div
4 |
5 | Permission is hereby granted by the authors of this software, to any person,
6 | to use the software for any purpose, free of charge, including the rights to
7 | run, read, copy, change, distribute and sell it, and including usage rights to
8 | any patents the authors may hold on it, subject to the following conditions:
9 |
10 | This license, or a link to its text, must be included with all copies of the
11 | software and any derivative works.
12 |
13 | Any modification to the software submitted to the authors may be incorporated
14 | into the software under the terms of this license.
15 |
16 | The software is provided "as is", without warranty of any kind, including but
17 | not limited to the warranties of title, fitness, merchantability and non-
18 | infringement. The authors have no obligation to provide support or updates for
19 | the software, and may not be held liable for any damages, claims or other
20 | liability arising from its use.
21 |
--------------------------------------------------------------------------------
/create-schnack/index.js:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env node
2 | /* eslint no-console: "off" */
3 | const fs = require('fs');
4 | const path = require('path');
5 | const { spawn } = require('child_process');
6 | const setup = require('./setup');
7 | const { white, green } = require('chalk');
8 |
9 | const CWD = process.env.INIT_CWD || process.cwd();
10 | let tag = process.argv.find(arg => arg.includes('--tag')) || '--tag=latest';
11 | tag = tag.split('=')[1];
12 |
13 | async function main() {
14 | const configPath = path.join(CWD, 'schnack.json');
15 |
16 | if (!fs.existsSync(configPath)) {
17 | await setup();
18 | }
19 |
20 | const { plugins, schnack_host } = require(configPath);
21 |
22 | // create package.json file if it doesn't exist
23 | if (!fs.existsSync(path.join(CWD, 'package.json'))) {
24 | console.log(`[init] Initialize ${green('package.json')} file.`);
25 | const pkg = {
26 | name: schnack_host.replace(/https?:\/\//, ''),
27 | version: '1.0.0',
28 | private: true,
29 | scripts: {
30 | start: 'schnack'
31 | }
32 | };
33 | fs.writeFileSync(path.join(CWD, 'package.json'), JSON.stringify(pkg, null, 4), {
34 | encoding: 'utf-8'
35 | });
36 | }
37 |
38 | const packages = Object.keys(plugins || {})
39 | .filter(id => id !== 'notify-webpush')
40 | .map(id => `${plugins[id].pkg || `@schnack/plugin-${id}`}@latest`);
41 |
42 | console.log('[npm] Start package installation.');
43 | const npm = spawn('npm', ['install', '-SE', '--production', `schnack@${tag}`].concat(packages));
44 |
45 | npm.stdout.on('data', data => process.stdout.write(data));
46 | npm.stderr.on('data', data => process.stderr.write(data));
47 |
48 | npm.on('close', () => {
49 | console.log(`\nrun ${white('npm start')} to start your Schnack server.`);
50 | });
51 | }
52 |
53 | main();
54 |
--------------------------------------------------------------------------------
/create-schnack/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-schnack",
3 | "version": "0.3.3",
4 | "description": "",
5 | "license": "LicenseRef-LICENSE",
6 | "author": "Gregor Aisch ",
7 | "main": "index.js",
8 | "bin": "index.js",
9 | "scripts": {
10 | "format": "prettier '*.js' --write",
11 | "lint": "prettier --check '*.{js,html}' && healthier '*.{js,html}'",
12 | "test": "echo \"Error: no test specified\" && exit 1"
13 | },
14 | "dependencies": {
15 | "chalk": "^4.1.0",
16 | "enquirer": "^2.3.6",
17 | "nanoid": "^3.1.20",
18 | "node-fetch": "^2.6.1"
19 | },
20 | "prettier": {
21 | "tabWidth": 4,
22 | "semi": true,
23 | "printWidth": 100,
24 | "singleQuote": true
25 | },
26 | "eslintConfig": {
27 | "parser": "babel-eslint",
28 | "rules": {
29 | "no-console": [
30 | "error",
31 | {
32 | "allow": [
33 | "warn",
34 | "error"
35 | ]
36 | }
37 | ],
38 | "camelcase": [
39 | "warn",
40 | {
41 | "ignoreDestructuring": true,
42 | "properties": "never"
43 | }
44 | ]
45 | }
46 | },
47 | "devDependencies": {
48 | "babel-eslint": "^10.0.1",
49 | "healthier": "^2.0.0",
50 | "prettier": "^1.16.4"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/create-schnack/readme.md:
--------------------------------------------------------------------------------
1 | # create-schnack
2 |
3 | You can use this script to setup Schnack.
4 |
5 | ```bash
6 | mkdir my-schnack
7 | cd my-schnack
8 | npm init schnack
9 | ```
10 |
11 | ### What does it do?
12 |
13 | * check if a Schnack config file (called `schnack.json` from now on) exists
14 | * if it doesn't exist, copy the `schnack.tpl.json` to `schnack.json` and exit with a message asking the user to edit the config file and then run `npm init schnack` again
15 | * if the config file exists, the init script installs `schnack` and the configured schnack plugins
16 |
--------------------------------------------------------------------------------
/create-schnack/setup.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: "off" */
2 | const fetch = require('node-fetch');
3 | const { prompt, Confirm } = require('enquirer');
4 | const path = require('path');
5 | const fs = require('fs');
6 | const { nanoid } = require('nanoid');
7 | const { white, green } = require('chalk');
8 |
9 | const CWD = process.env.INIT_CWD || process.cwd();
10 |
11 | async function setup() {
12 | // load default config from github
13 | const res = await fetch(
14 | 'https://raw.githubusercontent.com/schn4ck/schnack/master/schnack.tpl.json'
15 | );
16 | const defaultConfig = await res.json();
17 |
18 | console.log(`No ${green('schnack.json')} found in this directory. Copying default config from Github...`);
19 |
20 | const configureNow = await (new Confirm({
21 | message: `Do you want to configure your Schnack server now?`
22 | })).run();
23 |
24 | if (configureNow) {
25 | const response = await prompt([
26 | {
27 | type: 'input',
28 | name: 'schnack_host',
29 | message: 'Under what hostname will your Schnack server be running under?',
30 | initial: defaultConfig.schnack_host
31 | }, {
32 | type: 'input',
33 | name: 'port',
34 | message: 'Under what port will your Schnack server be reachable?',
35 | initial: defaultConfig.port
36 | }, {
37 | type: 'input',
38 | name: 'page_url',
39 | message: 'How does the URL pattern for your pages look like (use %SLUG%)?',
40 | hint: 'Use the placeholder %SLUG% for your page permalinks',
41 | initial: defaultConfig.page_url
42 | }, {
43 | type: 'multiselect',
44 | name: 'plugins',
45 | message: 'Select which plugins you want to enable',
46 | hint: 'Use [space] to select multiple plugins',
47 | choices: Object.keys(defaultConfig.plugins)
48 | }
49 | ]);
50 |
51 | Object.keys(response).forEach(key => {
52 | if (key === 'oauth_secret')
53 | if (key !== 'plugins') defaultConfig[key] = response[key];
54 | if (key !== 'plugins') defaultConfig[key] = response[key];
55 | })
56 |
57 | defaultConfig.oauth.secret = nanoid()
58 |
59 | const { plugins } = defaultConfig;
60 | defaultConfig.plugins = {};
61 |
62 | for (var i = 0; i < response.plugins.length; i++) {
63 | const plugin = response.plugins[i];
64 | const configureNow = await (new Confirm({
65 | message: `Do you want to configure ${white(plugin)} now?`
66 | })).run();
67 | if (configureNow) {
68 | const res = await prompt(Object.keys(plugins[plugin]).map(key => ({
69 | type: 'input',
70 | name: key,
71 | message: `Enter the value for ${plugin}.${white(key)}:`,
72 | initial: plugins[plugin][key]
73 | })));
74 | defaultConfig.plugins[plugin] = res;
75 | } else {
76 | defaultConfig.plugins[plugin] = plugins[plugin];
77 | }
78 | }
79 | }
80 |
81 | fs.writeFileSync(path.join(CWD, 'schnack.json'), JSON.stringify(defaultConfig, null, 4), {
82 | encoding: 'utf-8'
83 | });
84 |
85 | console.log(`Wrote ${green('schnack.json')}.`);
86 |
87 | if (!configureNow) {
88 | console.log(`Please edit ${green('schnack.json')} and then run ${white('npm init schnack')} again.`);
89 | process.exit();
90 | }
91 |
92 | }
93 |
94 | module.exports = setup;
95 |
96 |
--------------------------------------------------------------------------------
/docs/migrating-to-schnack-1.md:
--------------------------------------------------------------------------------
1 | # Migrating from Schnack 0.x to 1.x
2 |
3 | Two major things have changed in version 1.0: the way Schnack is being installed and the name and format of the config file. Also all the authentication and notification providers are now [plugins](https://github.com/schn4ck/schnack-plugins/)!
4 |
5 | Here's how you migrate your existing Schnack server to the new setup:
6 |
7 | - Create a new folder for schnack
8 | - Copy your old database files to the new folder (e.g., `comments.db` and `sessions.db`)
9 | - Copy your old `config.json` to the new folder
10 | - Rename `config.json` to `schnack.json`
11 | - In the config file you need to move the config sections for the auth and notify providers into the new `plugins` section (see below)
12 | - Then run `npm init schnack` in your new folder to install Schnack and the plugins
13 | - Now start schnack with `npm start`
14 |
15 | Before:
16 |
17 | ```js
18 | {
19 | "auth": {
20 | "twitter": {
21 | "consumer_key": "xxxxx",
22 | "consumer_secret": "xxxxx"
23 | }
24 | }
25 | }
26 | ```
27 |
28 | After:
29 |
30 | ```js
31 | {
32 | "plugins": {
33 | "auth-twitter": {
34 | "consumer_key": "xxxxx",
35 | "consumer_secret": "xxxxx"
36 | }
37 | }
38 | }
39 | ```
40 |
41 | Here's the full list of all changed config paths
42 |
43 | ```
44 | auth.facebook --> plugins.auth-facebook
45 | auth.github --> plugins.auth-github
46 | auth.google --> plugins.auth-google
47 | auth.mastodon --> plugins.auth-mastodon
48 | auth.twitter --> plugins.auth-twitter
49 | notify.webpush --> plugins.notify-webpush
50 | notify.pushover --> plugins.notify-pushover
51 | notify.sendmail --> plugins.notify-sendmail
52 | notify.slack --> plugins.notify-slack
53 | ```
54 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | require('./src/server');
--------------------------------------------------------------------------------
/migrations/001-initial-schema.sql:
--------------------------------------------------------------------------------
1 | -- Up
2 | CREATE TABLE comment (
3 | id INTEGER PRIMARY KEY NOT NULL,
4 | user_id NOT NULL,
5 | slug CHAR(128) NOT NULL,
6 | created_at TEXT NOT NULL,
7 | comment CHAR(4000) NOT NULL,
8 | rejected BOOLEAN,
9 | approved BOOLEAN
10 | );
11 |
12 | CREATE TABLE user (
13 | id INTEGER PRIMARY KEY NOT NULL,
14 | name CHAR(128),
15 | display_name CHAR(128),
16 | provider CHAR(128),
17 | provider_id CHAR(128),
18 | created_at TEXT NOT NULL,
19 | blocked BOOLEAN,
20 | trusted BOOLEAN
21 | );
22 |
23 | -- Down
24 | DROP TABLE comment;
25 | DROP TABLE user;
26 |
--------------------------------------------------------------------------------
/migrations/002-notifications.sql:
--------------------------------------------------------------------------------
1 | -- Up
2 | CREATE TABLE setting (
3 | name CHAR(128) PRIMARY KEY NOT NULL,
4 | active BOOLEAN NOT NULL
5 | );
6 |
7 | INSERT INTO setting (name, active) VALUES ('notification', 1);
8 |
9 | CREATE TABLE subscription (
10 | endpoint CHAR(600) PRIMARY KEY NOT NULL,
11 | publicKey CHAR(4096) NOT NULL,
12 | auth CHAR(600) NOT NULL
13 | );
14 |
15 | -- Down
16 | DROP TABLE setting;
17 | DROP TABLE subscription;
18 |
--------------------------------------------------------------------------------
/migrations/003-replies.sql:
--------------------------------------------------------------------------------
1 | -- Up
2 | ALTER TABLE comment ADD COLUMN reply_to INTEGER;
3 |
4 | -- Down
5 | ALTER TABLE comment RENAME TO comment_old;
6 | CREATE TABLE comment (
7 | id INTEGER PRIMARY KEY NOT NULL,
8 | user_id NOT NULL,
9 | slug CHAR(128) NOT NULL,
10 | created_at TEXT NOT NULL,
11 | comment CHAR(4000) NOT NULL,
12 | rejected BOOLEAN,
13 | approved BOOLEAN
14 | );
15 | INSERT INTO comment SELECT id, user_id, slug, created_at, comment, rejected, approved FROM comment_old;
16 | DROP TABLE comment_old;
--------------------------------------------------------------------------------
/migrations/004-indices.sql:
--------------------------------------------------------------------------------
1 | -- Up
2 | CREATE UNIQUE INDEX idx_user_id ON user(id);
3 | CREATE INDEX idx_user_blocked ON user(blocked);
4 | CREATE INDEX idx_user_trusted ON user(trusted);
5 | CREATE UNIQUE INDEX idx_comment_id ON comment(id);
6 | CREATE INDEX idx_comment_approved ON comment(approved);
7 | CREATE INDEX idx_comment_created_at ON comment(created_at);
8 | CREATE INDEX idx_comment_rejected ON comment(rejected);
9 | CREATE INDEX idx_comment_user_id ON comment(user_id);
10 | CREATE UNIQUE INDEX idx_setting_name ON setting(name);
11 | CREATE INDEX idx_subscription_endpoint ON subscription(endpoint);
12 |
13 | -- Down
14 | DROP INDEX idx_user_id;
15 | DROP INDEX idx_user_blocked;
16 | DROP INDEX idx_user_trusted;
17 | DROP INDEX idx_comment_id;
18 | DROP INDEX idx_comment_approved;
19 | DROP INDEX idx_comment_created_at;
20 | DROP INDEX idx_comment_rejected;
21 | DROP INDEX idx_comment_user_id;
22 | DROP INDEX idx_setting_name;
23 | DROP INDEX idx_subscription_endpoint;
24 |
--------------------------------------------------------------------------------
/migrations/005-unique-provider_id.sql:
--------------------------------------------------------------------------------
1 | -- Up
2 | CREATE UNIQUE INDEX idx_user_provider_id ON user(provider, provider_id);
3 |
4 | -- Down
5 | DROP INDEX idx_user_provider_id;
6 |
--------------------------------------------------------------------------------
/migrations/006-user-url.sql:
--------------------------------------------------------------------------------
1 | -- Up
2 | ALTER TABLE user ADD COLUMN url CHAR(255);
3 |
4 | -- Down
5 | ALTER TABLE user RENAME TO user_old;
6 | CREATE TABLE user (
7 | id INTEGER PRIMARY KEY NOT NULL,
8 | name CHAR(128),
9 | display_name CHAR(128),
10 | provider CHAR(128),
11 | provider_id CHAR(128),
12 | created_at TEXT NOT NULL,
13 | blocked BOOLEAN,
14 | trusted BOOLEAN
15 | );
16 | INSERT INTO user SELECT id, name, display_name, provider, provider_id,
17 | created_at, blocked, trusted FROM user_old;
18 | DROP TABLE user_old;
19 |
--------------------------------------------------------------------------------
/migrations/007-oauth-providers.sql:
--------------------------------------------------------------------------------
1 | -- Up
2 | CREATE TABLE oauth_provider (
3 | id INTEGER PRIMARY KEY NOT NULL,
4 | provider CHAR(128),
5 | provider_app_id CHAR(255),
6 | domain CHAR(255),
7 | client_id CHAR(255),
8 | client_secret CHAR(255),
9 | created_at TEXT NOT NULL
10 | );
11 |
12 | -- Down
13 | DROP TABLE oauth_provider;
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "schnack",
3 | "version": "1.2.0",
4 | "description": "a simple node app for disqus-like drop-in commenting on static websites",
5 | "license": "LicenseRef-LICENSE",
6 | "author": "Gregor Aisch",
7 | "contributors": [
8 | {
9 | "name": "Gregor Aisch",
10 | "web": "https://driven-by-data.net"
11 | },
12 | {
13 | "name": "Moritz Klack",
14 | "web": "https://moritzklack.com"
15 | },
16 | {
17 | "name": "g-div",
18 | "web": "https://github.com/g-div"
19 | }
20 | ],
21 | "bin": {
22 | "schnack": "src/server.js"
23 | },
24 | "main": "index.js",
25 | "files": [
26 | "config.tpl.json",
27 | "build",
28 | "migrations",
29 | "src"
30 | ],
31 | "scripts": {
32 | "format": "prettier 'src/**/*.js' --write",
33 | "lint": "prettier --check 'src/**/*.js' && healthier 'src/**/*.js'",
34 | "start": "node index.js",
35 | "build": "rollup -c",
36 | "build-watch": "rollup -cw",
37 | "test-server": "node index.js --dev",
38 | "server": "NODE_ENV=development nodemon index.js",
39 | "import": "node src/importer.js",
40 | "dev": "node index.js --dev & rollup -cw",
41 | "version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md"
42 | },
43 | "repository": "git@github.com:gka/schnack.git",
44 | "dependencies": {
45 | "body-parser": "^1.19.0",
46 | "connect-sqlite3": "^0.9.11",
47 | "cors": "^2.8.5",
48 | "express": "^4.17.1",
49 | "express-session": "^1.17.1",
50 | "insane": "^2.6.2",
51 | "lodash.countby": "^4.6.0",
52 | "marked": "^1.2.7",
53 | "moment": "^2.29.1",
54 | "nconf": "^0.11.1",
55 | "passport": "^0.4.1",
56 | "rss": "^1.2.2",
57 | "sqlite": "^4.0.19",
58 | "sqlite3": "^4.2.0",
59 | "unfetch": "^4.2.0",
60 | "web-push": "^3.4.4"
61 | },
62 | "devDependencies": {
63 | "@rollup/plugin-buble": "^0.21.3",
64 | "@rollup/plugin-commonjs": "^17.0.0",
65 | "@rollup/plugin-node-resolve": "^11.1.0",
66 | "babel-eslint": "^10.1.0",
67 | "conventional-changelog-cli": "^2.1.1",
68 | "healthier": "^2.0.0",
69 | "http-server": "^0.12.3",
70 | "jst": "0.0.13",
71 | "nodemon": "^2.0.7",
72 | "prettier": "^1.16.4",
73 | "rollup": "^2.36.1",
74 | "rollup-plugin-jst": "^1.2.0",
75 | "rollup-plugin-terser": "^7.0.2",
76 | "to-markdown": "^3.1.0",
77 | "xml2js": "^0.4.23"
78 | },
79 | "prettier": {
80 | "tabWidth": 4,
81 | "semi": true,
82 | "printWidth": 100,
83 | "singleQuote": true
84 | },
85 | "eslintConfig": {
86 | "parser": "babel-eslint",
87 | "rules": {
88 | "no-console": [
89 | "error",
90 | {
91 | "allow": [
92 | "warn",
93 | "error"
94 | ]
95 | }
96 | ],
97 | "camelcase": [
98 | "off"
99 | ]
100 | }
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | // rollup.config.js
2 | import { nodeResolve } from '@rollup/plugin-node-resolve';
3 | import commonjs from '@rollup/plugin-commonjs';
4 | import buble from '@rollup/plugin-buble';
5 | import { terser } from "rollup-plugin-terser";
6 | import jst from 'rollup-plugin-jst';
7 |
8 | const plugins = [
9 | jst({
10 | extensions: ['.html'],
11 | include: 'src/embed/**.html'
12 | }),
13 | commonjs(),
14 | nodeResolve(),
15 | buble(),
16 | terser()
17 | ];
18 |
19 | export default [{
20 | input: 'src/embed/index.js',
21 | output: {
22 | file: 'build/embed.js',
23 | format: 'iife'
24 | },
25 | plugins,
26 | watch: {
27 | clearScreen: false
28 | }
29 | }, {
30 | input: 'src/embed/client.js',
31 | output: {
32 | file: 'build/client.js',
33 | format: 'umd',
34 | name: 'Schnack'
35 | },
36 | plugins,
37 | watch: {
38 | clearScreen: false
39 | }
40 | }];
41 |
--------------------------------------------------------------------------------
/schnack.tpl.json:
--------------------------------------------------------------------------------
1 | {
2 | "schnack_host": "https://schnack.example.com",
3 | "page_url": "https://blog.example.com/posts/%SLUG%",
4 | "port": 3000,
5 | "database": {
6 | "comments": "comments.db",
7 | "sessions": "sessions.db"
8 | },
9 | "admins": [1],
10 | "plugins": {
11 | "auth-twitter": {
12 | "consumer_key": "xxxxx",
13 | "consumer_secret": "xxxxx"
14 | },
15 | "auth-github": {
16 | "client_id": "xxxxx",
17 | "client_secret": "xxxxx"
18 | },
19 | "auth-google": {
20 | "client_id": "xxxxx",
21 | "client_secret": "xxxxx"
22 | },
23 | "auth-facebook": {
24 | "client_id": "xxxxx",
25 | "client_secret": "xxxxx"
26 | },
27 | "auth-mastodon": {
28 | "app_name": "your website name",
29 | "app_website": "https://blog.example.com/"
30 | },
31 | "notify-pushover": {
32 | "app_token": "xxxxx",
33 | "user_key": "xxxxx"
34 | },
35 | "notify-webpush": {
36 | "vapid_public_key": "xxxxx",
37 | "vapid_private_key": "xxxxx"
38 | },
39 | "notify-slack": {
40 | "webhook_url": "xxxxx"
41 | },
42 | "notify-sendmail": {
43 | "to": "admin@blog.example.com",
44 | "from": "schnack@blog.example.com"
45 | }
46 | },
47 | "oauth": {
48 | "secret": "xxxxx"
49 | },
50 | "date_format": "MMMM DD, YYYY - h:mm a"
51 | }
52 |
--------------------------------------------------------------------------------
/src/auth.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const session = require('express-session');
3 | const SQLiteStore = require('connect-sqlite3')(session);
4 |
5 | const queries = require('./db/queries');
6 | const config = require('./config');
7 | const authConfig = config.get('oauth');
8 | const trustConfig = config.get('trust');
9 | const { plugins } = require('./plugins');
10 |
11 | const providers = [];
12 |
13 | const authPlugins = [];
14 |
15 | function init(app, db, domain) {
16 | app.use(
17 | session({
18 | resave: false,
19 | saveUninitialized: false,
20 | secret: authConfig.secret,
21 | cookie: { domain: `.${domain}` },
22 | store: new SQLiteStore({ db: config.get('database').sessions })
23 | })
24 | );
25 |
26 | app.use(passport.initialize());
27 | app.use(passport.session());
28 |
29 | passport.serializeUser(async (user, done) => {
30 | const existingUser = await db.get(queries.find_user, [user.provider, user.id]).catch(err => {
31 | console.error('could not find user', err);
32 | throw err;
33 | });
34 |
35 | if (existingUser) return done(null, existingUser); // welcome back
36 | // nice to meet you, new user!
37 | // check if id shows up in auto-trust config
38 | var trusted =
39 | trustConfig &&
40 | trustConfig[user.provider] &&
41 | trustConfig[user.provider].indexOf(user.id) > -1
42 | ? 1
43 | : 0;
44 | const c_args = [
45 | user.provider,
46 | user.id,
47 | user.displayName,
48 | user.username || user.displayName,
49 | user.profileUrl || '',
50 | trusted
51 | ];
52 | await db.run(queries.create_user, c_args).catch(err => {
53 | console.error('could not create user', err);
54 | throw err;
55 | });
56 | const newUser = await db.get(queries.find_user, [user.provider, user.id]).catch(err => {
57 | console.error('could not find user after insert', err);
58 | throw err;
59 | });
60 | if (newUser) {
61 | return done(null, newUser);
62 | }
63 | console.error('no user found after insert');
64 | });
65 |
66 | passport.deserializeUser((user, done) => {
67 | done(null, {
68 | provider: user.provider,
69 | id: user.provider_id
70 | });
71 | });
72 |
73 | // initialize auth plugins
74 | plugins.forEach(plugin => {
75 | if (typeof plugin.auth === 'function') {
76 | authPlugins.push(
77 | plugin.auth({
78 | providers,
79 | passport,
80 | app
81 | })
82 | );
83 | }
84 | });
85 | }
86 |
87 | function getAuthorUrl(comment) {
88 | if (comment.user_url) return comment.user_url;
89 | for (let i = 0; i < authPlugins.length; i++) {
90 | if (authPlugins[i] && typeof authPlugins[i].getAuthorUrl === 'function') {
91 | const url = authPlugins[i].getAuthorUrl(comment);
92 | if (url) return url;
93 | }
94 | }
95 | return false;
96 | }
97 |
98 | module.exports = {
99 | init,
100 | providers,
101 | getAuthorUrl
102 | };
103 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | const nconf = require('nconf');
2 | const crypto = require('crypto');
3 |
4 | nconf
5 | .argv()
6 | .file({ file: './schnack.json' })
7 | .env()
8 | .defaults({
9 | admins: [1],
10 | schnack_host: `http://localhost`,
11 | database: {
12 | comments: 'comments.db',
13 | sessions: 'sessions.db'
14 | },
15 | port: 3000,
16 | plugins: {},
17 | template: {
18 | login_status:
19 | '(signed in as %USER% :: sign out )'
20 | },
21 | date_format: 'MMMM DD, YYYY - h:mm a',
22 | notification_interval: 300000,
23 | oauth: {
24 | secret: crypto.randomBytes(64).toString('hex')
25 | }
26 | });
27 |
28 | module.exports = nconf;
29 |
--------------------------------------------------------------------------------
/src/db/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const { open } = require('sqlite');
3 | const sqlite3 = require('sqlite3');
4 | const config = require('../config');
5 | const conf = config.get('database');
6 |
7 | // returns promise that passes db obj
8 | module.exports = {
9 | async init() {
10 | const dbname = conf.comments || conf;
11 | const dbpath = path.resolve(process.cwd(), dbname);
12 |
13 | try {
14 | const db = await open({ filename: dbpath, driver: sqlite3.Database });
15 | await db.migrate({
16 | migrationsPath: path.resolve(__dirname, '../../migrations'),
17 | // force: process.env.NODE_ENV === 'development' ? 'last' : false
18 | force: false
19 | });
20 | return db;
21 | } catch (err) {
22 | console.error(err);
23 | }
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/src/db/queries.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | get_comments: `SELECT comment.id, user_id, user.name, user.display_name,
3 | user.url author_url, comment.created_at, comment, approved,
4 | trusted, provider, reply_to
5 | FROM comment INNER JOIN user ON (user_id=user.id)
6 | WHERE slug = ? AND ((
7 | NOT user.blocked AND NOT comment.rejected
8 | AND (comment.approved OR user.trusted))
9 | OR user.id = ?)
10 | ORDER BY comment.created_at DESC`,
11 |
12 | admin_get_comments: `SELECT user_id, user.name, user.display_name, comment.id,
13 | user.url author_url, comment.created_at, comment, approved, trusted, provider,
14 | reply_to
15 | FROM comment INNER JOIN user ON (user_id=user.id)
16 | WHERE slug = ? AND NOT user.blocked
17 | AND NOT comment.rejected
18 | ORDER BY comment.created_at DESC`,
19 |
20 | get_last_comment: `SELECT comment, user_id FROM comment WHERE slug = ?
21 | ORDER BY comment.created_at DESC LIMIT 1`,
22 |
23 | approve: `UPDATE comment SET approved = 1 WHERE id = ?`,
24 |
25 | reject: `UPDATE comment SET rejected = 1 WHERE id = ?`,
26 |
27 | trust: `UPDATE user SET trusted = 1 WHERE id = ?`,
28 |
29 | block: `UPDATE user SET blocked = 1 WHERE id = ?`,
30 |
31 | awaiting_moderation: `SELECT comment.id, slug, comment.created_at
32 | FROM comment INNER JOIN user ON (user_id=user.id)
33 | WHERE NOT user.blocked AND NOT user.trusted AND
34 | NOT comment.rejected AND NOT comment.approved
35 | ORDER BY comment.created_at DESC LIMIT 20`,
36 |
37 | insert: `INSERT INTO comment
38 | (user_id, slug, comment, reply_to, created_at, approved, rejected)
39 | VALUES (?,?,?,?,datetime(),0,0)`,
40 |
41 | find_user: `SELECT id, name, display_name, provider, provider_id,
42 | trusted, blocked FROM user
43 | WHERE provider = ? AND provider_id = ?`,
44 |
45 | create_user: `INSERT INTO user
46 | (provider, provider_id, display_name, name, url,
47 | created_at, trusted, blocked)
48 | VALUES (?, ?, ?, ?, ?, datetime(), ?, 0)`,
49 |
50 | set_settings: `INSERT OR REPLACE INTO setting (name, active)
51 | VALUES (?, ?)`,
52 |
53 | get_settings: `SELECT active FROM setting WHERE name = ?`,
54 |
55 | subscribe: `INSERT INTO subscription
56 | (endpoint, publicKey, auth)
57 | VALUES (?, ?, ?)`,
58 |
59 | unsubscribe: `DELETE FROM subscription
60 | WHERE endpoint = ?`,
61 |
62 | get_subscriptions: `SELECT endpoint, publicKey, auth FROM subscription`,
63 |
64 | find_oauth_provider: `SELECT id, provider, domain, client_id, client_secret FROM oauth_provider
65 | WHERE provider = ? AND domain = ?`,
66 |
67 | create_oauth_provider: `INSERT INTO oauth_provider
68 | (provider, domain, provider_app_id, client_id, client_secret, created_at)
69 | VALUES (?, ?, ?, ?, ?, datetime())`
70 | };
71 |
--------------------------------------------------------------------------------
/src/embed/client.js:
--------------------------------------------------------------------------------
1 | import fetch from 'unfetch';
2 | import schnack_tpl from './schnack.jst.html';
3 | import comments_tpl from './comments.jst.html';
4 |
5 | const $ = sel => document.querySelector(sel);
6 | const $$ = sel => document.querySelectorAll(sel);
7 |
8 | export default class Schnack {
9 | constructor(options) {
10 | this.options = options;
11 | this.options.endpoint = `${options.host}/comments/${options.slug}`;
12 | this.initialized = false;
13 | this.firstLoad = true;
14 |
15 | const url = new URL(options.host);
16 |
17 | if (url.hostname !== 'localhost') {
18 | document.domain = url.hostname
19 | .split('.')
20 | .slice(1)
21 | .join('.');
22 | }
23 |
24 | this.refresh();
25 | }
26 |
27 | refresh() {
28 | const { target, slug, host, endpoint, partials } = this.options;
29 |
30 | fetch(endpoint, {
31 | credentials: 'include',
32 | headers: {
33 | 'Content-Type': 'application/json'
34 | }
35 | })
36 | .then(r => r.json())
37 | .then(data => {
38 | data.comments_tpl = comments_tpl;
39 | data.partials = partials;
40 | $(target).innerHTML = schnack_tpl(data);
41 | // console.log('data', data);
42 |
43 | const above = $(`${target} div.schnack-above`);
44 | const form = $(`${target} div.schnack-form`);
45 | const textarea = $(`${target} textarea.schnack-body`);
46 | const preview = $(`${target} .schnack-form blockquote.schnack-body`);
47 |
48 | const draft = window.localStorage.getItem(`schnack-draft-${slug}`);
49 | if (draft && textarea) textarea.value = draft;
50 |
51 | const postBtn = $(target + ' .schnack-button');
52 | const previewBtn = $(target + ' .schnack-preview');
53 | const writeBtn = $(target + ' .schnack-write');
54 | const cancelReplyBtn = $(target + ' .schnack-cancel-reply');
55 | const replyBtns = $$(target + ' .schnack-reply');
56 |
57 | if (postBtn) {
58 | postBtn.addEventListener('click', d => {
59 | const body = textarea.value;
60 | fetch(endpoint, {
61 | credentials: 'include',
62 | method: 'POST',
63 | headers: {
64 | 'Content-Type': 'application/json'
65 | },
66 | body: JSON.stringify({
67 | comment: body,
68 | replyTo: form.dataset.reply
69 | })
70 | })
71 | .then(r => r.json())
72 | .then(res => {
73 | textarea.value = '';
74 | window.localStorage.setItem(
75 | `schnack-draft-${slug}`,
76 | textarea.value
77 | );
78 | if (res.id) {
79 | this.firstLoad = true;
80 | window.location.hash = '#comment-' + res.id;
81 | }
82 | this.refresh();
83 | });
84 | });
85 |
86 | previewBtn.addEventListener('click', d => {
87 | const body = textarea.value;
88 | textarea.style.display = 'none';
89 | previewBtn.style.display = 'none';
90 | preview.style.display = 'block';
91 | writeBtn.style.display = 'inline';
92 | fetch(`${host}/markdown`, {
93 | credentials: 'include',
94 | method: 'POST',
95 | headers: {
96 | 'Content-Type': 'application/json'
97 | },
98 | body: JSON.stringify({
99 | comment: body
100 | })
101 | })
102 | .then(r => r.json())
103 | .then(res => {
104 | preview.innerHTML = res.html;
105 | // refresh();
106 | });
107 | });
108 |
109 | writeBtn.addEventListener('click', d => {
110 | textarea.style.display = 'inline';
111 | previewBtn.style.display = 'inline';
112 | preview.style.display = 'none';
113 | writeBtn.style.display = 'none';
114 | });
115 |
116 | textarea.addEventListener('keyup', () => {
117 | window.localStorage.setItem(`schnack-draft-${slug}`, textarea.value);
118 | });
119 |
120 | replyBtns.forEach(btn => {
121 | btn.addEventListener('click', () => {
122 | form.dataset.reply = btn.dataset.replyTo;
123 | cancelReplyBtn.style.display = 'inline-block';
124 | btn.parentElement.appendChild(form);
125 | });
126 | });
127 |
128 | cancelReplyBtn.addEventListener('click', () => {
129 | above.appendChild(form);
130 | delete form.dataset.reply;
131 | cancelReplyBtn.style.display = 'none';
132 | });
133 | }
134 | if (data.user) {
135 | const signout = $('a.schnack-signout');
136 | if (signout)
137 | signout.addEventListener('click', e => {
138 | e.preventDefault();
139 | fetch(`${host}/signout`, {
140 | credentials: 'include',
141 | headers: {
142 | 'Content-Type': 'application/json'
143 | }
144 | }).then(() => this.refresh());
145 | });
146 | } else {
147 | data.auth.forEach(provider => {
148 | const btn = $(target + ' .schnack-signin-' + provider.id);
149 | if (btn)
150 | btn.addEventListener('click', d => {
151 | const signin = (provider_domain = '') => {
152 | let windowRef = window.open(
153 | `${host}/auth/${provider.id}` +
154 | (provider_domain ? `/d/${provider_domain}` : ''),
155 | provider.name + ' Sign-In',
156 | 'resizable,scrollbars,status,width=600,height=500'
157 | );
158 | window.__schnack_wait_for_oauth = () => {
159 | windowRef.close();
160 | this.refresh();
161 | };
162 | };
163 | if (provider.id === 'mastodon') {
164 | // we need to ask the user what instance they want to sign on
165 | const masto_domain = window.prompt(
166 | 'Please enter the domain name of the Mastodon instance you want to sign in with:',
167 | 'mastodon.social'
168 | );
169 | // test if the instance is correct
170 | fetch(`https://${masto_domain}/api/v1/instance`)
171 | .then(r => r.json())
172 | .then(res => {
173 | if (res.uri === masto_domain) {
174 | // instance seems to be fine!
175 | signin(masto_domain);
176 | } else {
177 | window.alert(
178 | `We could not find a Mastodon instance at "${masto_domain}". Please try again.`
179 | );
180 | }
181 | })
182 | .catch(err => {
183 | console.error(err);
184 | window.alert(
185 | `We could not find a Mastodon instance at "${masto_domain}". Please try again.`
186 | );
187 | });
188 | } else {
189 | signin();
190 | }
191 | });
192 | });
193 | }
194 |
195 | if (data.user && data.user.admin) {
196 | if (!this.initialized) {
197 | const push = document.createElement('script');
198 | push.setAttribute('src', `${host}/push.js`);
199 | document.head.appendChild(push);
200 | this.initialized = true;
201 | }
202 |
203 | const action = evt => {
204 | const btn = evt.target;
205 | const data = btn.dataset;
206 | fetch(`${host}/${data.class}/${data.target}/${data.action}`, {
207 | credentials: 'include',
208 | method: 'POST',
209 | headers: {
210 | 'Content-Type': 'application/json'
211 | },
212 | body: ''
213 | }).then(() => this.refresh());
214 | };
215 | document.querySelectorAll('.schnack-action').forEach(btn => {
216 | btn.addEventListener('click', action);
217 | });
218 | }
219 |
220 | if (this.firstLoad && window.location.hash.match(/^#comment-\d+$/)) {
221 | const hl = document.querySelector(window.location.hash);
222 | hl.scrollIntoView();
223 | hl.classList.add('schnack-highlight');
224 | this.firstLoad = false;
225 | }
226 | });
227 | }
228 | }
229 |
--------------------------------------------------------------------------------
/src/embed/comments.jst.html:
--------------------------------------------------------------------------------
1 |
36 |
--------------------------------------------------------------------------------
/src/embed/index.js:
--------------------------------------------------------------------------------
1 | import Schnack from './client';
2 |
3 | (function() {
4 | const script = document.querySelector('script[data-schnack-target]');
5 | if (!script) return console.warn('schnack script tag needs some data attributes');
6 |
7 | const opts = script.dataset;
8 | const slug = opts.schnackSlug;
9 | const url = new URL(script.getAttribute('src'));
10 | const host = `${url.protocol}//${url.host}`;
11 | const partials = {
12 | Preview: `Preview`,
13 | Edit: `Edit`,
14 | SendComment: `Send comment`,
15 | Cancel: `Cancel`,
16 | Or: `Or`,
17 | Mute: `mute notifications`,
18 | UnMute: `unmute`,
19 | PostComment: `Post a comment. Markdown is supported!`,
20 | AdminApproval: `This comment is still waiting for your approval`,
21 | WaitingForApproval: `Your comment is still waiting for approval by the site owner`,
22 | SignInVia: `To post a comment you need to sign in via`,
23 | Reply: ` reply`,
24 | LoginStatus:
25 | "(signed in as @%USER% :: sign out )",
26 | NoAuthProviders: `You haven't configured any auth providers, yet.`
27 | };
28 |
29 | Object.keys(partials).forEach(k => {
30 | if (script.dataset[`schnackPartial${k}`])
31 | partials[k] = script.dataset[`schnackPartial${k}`];
32 | });
33 |
34 | // eslint-disable-next-line no-new
35 | new Schnack({
36 | target: opts.schnackTarget,
37 | slug,
38 | host,
39 | partials
40 | });
41 | })();
42 |
--------------------------------------------------------------------------------
/src/embed/push.js:
--------------------------------------------------------------------------------
1 | /* globals btoa, fetch, Notification */
2 | // Vapid public key.
3 | const applicationServerPublicKey = '%VAPID_PUBLIC_KEY%';
4 | const schnack_host = '%SCHNACK_HOST%';
5 |
6 | const serviceWorkerName = '/sw.js';
7 |
8 | let isSubscribed = false;
9 | let swRegistration = null;
10 |
11 | (function() {
12 | Notification.requestPermission().then(function(status) {
13 | if (status === 'granted') {
14 | initialiseServiceWorker();
15 | }
16 | });
17 | })();
18 |
19 | function initialiseServiceWorker() {
20 | if ('serviceWorker' in navigator) {
21 | navigator.serviceWorker
22 | .register(serviceWorkerName)
23 | .then(handleSWRegistration)
24 | .catch(err => console.error(err));
25 | } else {
26 | console.error("Service workers aren't supported in this browser.");
27 | }
28 | }
29 |
30 | function handleSWRegistration(reg) {
31 | swRegistration = reg;
32 | initialiseState(reg);
33 | }
34 |
35 | // Once the service worker is registered set the initial state
36 | function initialiseState(reg) {
37 | // Are Notifications supported in the service worker?
38 | if (!reg.showNotification) {
39 | console.error("Notifications aren't supported on service workers.");
40 | return;
41 | }
42 |
43 | // Check if push messaging is supported
44 | if (!('PushManager' in window)) {
45 | console.error("Push messaging isn't supported.");
46 | return;
47 | }
48 |
49 | // We need the service worker registration to check for a subscription
50 | navigator.serviceWorker.ready.then(function(reg) {
51 | // Do we already have a push message subscription?
52 | reg.pushManager
53 | .getSubscription()
54 | .then(subscription => {
55 | if (!subscription) {
56 | isSubscribed = false;
57 | subscribe();
58 | } else {
59 | // initialize status, which includes setting UI elements for subscribed status
60 | // and updating Subscribers list via push
61 | isSubscribed = true;
62 | }
63 | })
64 | .catch(err => {
65 | console.error('Error during getSubscription()', err);
66 | });
67 | });
68 | }
69 |
70 | function subscribe() {
71 | navigator.serviceWorker.ready.then(function(reg) {
72 | const subscribeParams = { userVisibleOnly: true };
73 |
74 | // Setting the public key of our VAPID key pair.
75 | const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
76 | subscribeParams.applicationServerKey = applicationServerKey;
77 |
78 | reg.pushManager
79 | .subscribe(subscribeParams)
80 | .then(subscription => {
81 | // Update status to subscribe current user on server, and to let
82 | // other users know this user has subscribed
83 | const endpoint = subscription.endpoint;
84 | const key = subscription.getKey('p256dh');
85 | const auth = subscription.getKey('auth');
86 | sendSubscriptionToServer(endpoint, key, auth);
87 | isSubscribed = true;
88 | })
89 | .catch(err => {
90 | // A problem occurred with the subscription.
91 | console.error('Unable to subscribe to push.', err);
92 | });
93 | });
94 | }
95 |
96 | function unsubscribe() {
97 | let endpoint = null;
98 | swRegistration.pushManager
99 | .getSubscription()
100 | .then(subscription => {
101 | if (subscription) {
102 | endpoint = subscription.endpoint;
103 | return subscription.unsubscribe();
104 | }
105 | })
106 | .catch(error => {
107 | console.error('Error unsubscribing', error);
108 | })
109 | .then(() => {
110 | removeSubscriptionFromServer(endpoint);
111 |
112 | console.error('User is unsubscribed.');
113 | isSubscribed = false;
114 | });
115 | }
116 |
117 | function sendSubscriptionToServer(endpoint, key, auth) {
118 | const encodedKey = btoa(String.fromCharCode.apply(null, new Uint8Array(key)));
119 | const encodedAuth = btoa(String.fromCharCode.apply(null, new Uint8Array(auth)));
120 |
121 | fetch(schnack_host + '/subscribe', {
122 | method: 'POST',
123 | headers: { 'Content-Type': 'application/json' },
124 | body: JSON.stringify({ publicKey: encodedKey, auth: encodedAuth, endpoint })
125 | }).then(res => {
126 | // eslint-disable-next-line no-console
127 | console.log('Subscribed successfully! ' + JSON.stringify(res));
128 | });
129 | }
130 |
131 | function removeSubscriptionFromServer(endpoint) {
132 | const encodedKey = btoa(String.fromCharCode.apply(null, new Uint8Array(key)));
133 | const encodedAuth = btoa(String.fromCharCode.apply(null, new Uint8Array(auth)));
134 |
135 | fetch(schnack_host + '/unsubscribe', {
136 | method: 'POST',
137 | headers: { 'Content-Type': 'application/json' },
138 | body: JSON.stringify({ publicKey: encodedKey, auth: encodedAuth, endpoint })
139 | }).then(res => {
140 | // eslint-disable-next-line no-console
141 | console.log('Unsubscribed successfully! ' + JSON.stringify(res));
142 | });
143 | }
144 |
145 | function urlB64ToUint8Array(base64String) {
146 | const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
147 | const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
148 |
149 | const rawData = window.atob(base64);
150 | const outputArray = new Uint8Array(rawData.length);
151 |
152 | for (let i = 0; i < rawData.length; ++i) {
153 | outputArray[i] = rawData.charCodeAt(i);
154 | }
155 | return outputArray;
156 | }
157 |
--------------------------------------------------------------------------------
/src/embed/schnack.jst.html:
--------------------------------------------------------------------------------
1 | <% if (data.user) { %>
2 | <% if (data.user.admin) { %>
3 |
4 | <%= data.partials.UnMute %>
5 | <%= data.partials.Mute %>
6 |
7 | <% } %>
8 |
9 | <%= data.partials.LoginStatus.replace('%USER%', data.user.name) %>
10 |
11 |
22 | <% } else { %>
23 |
24 | <% if (!data.auth.length) { %>
25 | <%= data.partials.NoAuthProviders %>
26 | <% } else { %>
27 | <%= data.partials.SignInVia %>
28 | <% data.auth.forEach((provider, i) => { %>
29 | <%= i ? data.partials.Or : '' %> <%= provider.name %>
30 | <% }) %>
31 | <% } %>
32 | <% } %>
33 |
34 | <%
35 | const comments = [];
36 | data.replies = {};
37 | data.comments.forEach((comment) => {
38 | if (comment.reply_to) {
39 | if (!data.replies[comment.reply_to]) data.replies[comment.reply_to] = [];
40 | data.replies[comment.reply_to].push(comment);
41 | } else {
42 | comments.push(comment);
43 | }
44 | });
45 | data.comments = comments;
46 | %>
47 | <%= data.comments_tpl(data) %>
48 |
51 |
--------------------------------------------------------------------------------
/src/events.js:
--------------------------------------------------------------------------------
1 | const EventEmitter = require('events');
2 |
3 | class SchnackEventEmitter extends EventEmitter {}
4 |
5 | module.exports = new SchnackEventEmitter();
6 |
--------------------------------------------------------------------------------
/src/helper.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { URL } = require('url');
3 | const queries = require('./db/queries');
4 | const config = require('./config');
5 |
6 | const schnack_domain = getSchnackDomain();
7 | function sendFile(file, adminOnly, devMode) {
8 | return devMode ? function(request, reply, next) {
9 | if (adminOnly) {
10 | const user = getUser(request) || {};
11 | if (!user.admin) return next();
12 | }
13 | if (request.baseUrl.endsWith('.js')) {
14 | reply.header('Content-Type', 'application/javascript');
15 | }
16 | reply.send(fs.readFileSync(file, 'utf-8'));
17 | } : sendString(fs.readFileSync(file, 'utf-8'), adminOnly);
18 | }
19 |
20 | function sendString(body, adminOnly) {
21 | return function(request, reply, next) {
22 | if (adminOnly) {
23 | const user = getUser(request) || {};
24 | if (!user.admin) return next();
25 | }
26 | if (request.baseUrl.endsWith('.js')) {
27 | reply.header('Content-Type', 'application/javascript');
28 | }
29 | reply.send(body);
30 | };
31 | }
32 |
33 | function error(err, request, reply, code) {
34 | if (err) {
35 | console.error(err.message);
36 | reply.status(code || 500).send({ error: err.message });
37 |
38 | return true;
39 | }
40 |
41 | return false;
42 | }
43 |
44 | function getUser(request) {
45 | if (config.get('dev'))
46 | return { id: 1, name: 'Dev', display_name: 'Dev', admin: true, trusted: 1 };
47 | const { user } = request.session.passport || {};
48 | return user;
49 | }
50 |
51 | function isAdmin(user) {
52 | return user && user.id && config.get('admins').indexOf(user.id) > -1;
53 | }
54 |
55 | function checkOrigin(origin, callback) {
56 | // origin is allowed
57 | if (
58 | typeof origin === 'undefined' ||
59 | `.${new URL(origin).hostname}`.endsWith(`.${schnack_domain}`)
60 | ) {
61 | return callback(null, true);
62 | }
63 |
64 | callback(new Error('Not allowed by CORS'));
65 | }
66 |
67 | async function checkValidComment(db, slug, user_id, comment, replyTo) {
68 | if (comment.trim() === '') throw new Error("the comment can't be empty");
69 | // check duplicate comment
70 | try {
71 | const row = await db.get(queries.get_last_comment, [slug]);
72 | if (row && row.comment.trim() === comment && row.user_id === user_id) {
73 | throw new Error('the exact comment has been entered before');
74 | }
75 | } catch (err) {
76 | throw err;
77 | }
78 | }
79 |
80 | function getSchnackDomain() {
81 | const schnack_host = config.get('schnack_host');
82 | try {
83 | const schnack_url = new URL(schnack_host);
84 |
85 | if (schnack_url.hostname === 'localhost') {
86 | return schnack_url.hostname;
87 | } else {
88 | const schnack_domain = schnack_url.hostname
89 | .split('.')
90 | .slice(1)
91 | .join('.');
92 | return schnack_domain;
93 | }
94 | } catch (error) {
95 | console.error(
96 | `The schnack_host value "${schnack_host}" doesn't appear to be a proper URL. Did you forget "http://"?`
97 | );
98 | process.exit(-1);
99 | }
100 | }
101 |
102 | module.exports = {
103 | sendFile,
104 | sendString,
105 | error,
106 | getUser,
107 | isAdmin,
108 | checkOrigin,
109 | checkValidComment,
110 | getSchnackDomain
111 | };
112 |
--------------------------------------------------------------------------------
/src/importer.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const xml2js = require('xml2js');
4 | const sqlite = require('sqlite');
5 | const queries = require('./db/queries');
6 | const toMarkdown = require('to-markdown');
7 | const dbPromise = sqlite.open('./comments.db', { Promise });
8 | let db;
9 |
10 | const filename = process.argv.slice(2, 3).pop();
11 | if (!filename) {
12 | console.error('Pass the filepath to your XML file as argument');
13 | process.exit(1);
14 | }
15 |
16 | // Promisify readFile
17 | async function readFile(file) {
18 | const promise = await new Promise((resolve, reject) => {
19 | fs.readFile(file, (err, data) => {
20 | if (err) reject(err);
21 | else resolve(data.toString());
22 | });
23 | });
24 | return promise;
25 | }
26 |
27 | // Promisify parse XML
28 | async function parse(file) {
29 | const promise = await new Promise((resolve, reject) => {
30 | const parser = new xml2js.Parser({ explicitArray: false });
31 |
32 | parser.parseString(file, (error, result) => {
33 | if (error) reject(error);
34 | else resolve(result);
35 | });
36 | });
37 | return promise;
38 | }
39 |
40 | function getWPAuthor(comment) {
41 | return [
42 | 'wordpress',
43 | comment['wp:comment_author'],
44 | comment['wp:comment_author'],
45 | comment['wp:comment_author'],
46 | 0
47 | ];
48 | }
49 |
50 | function getWPComment(thread, comment) {
51 | return [
52 | thread['wp:post_name'],
53 | comment['wp:comment_content'],
54 | comment['wp:comment_parent'],
55 | comment['wp:comment_date'],
56 | comment['wp:comment_approved']
57 | ];
58 | }
59 |
60 | function formatWPComment(comment, thread) {
61 | return {
62 | author: getWPAuthor(comment),
63 | comment: getWPComment(thread, comment),
64 | id: comment['wp:comment_id']
65 | };
66 | }
67 |
68 | async function parseWP(data) {
69 | const threads = data.rss.channel.item;
70 | for (let thread of threads) {
71 | const comments = thread['wp:comment'];
72 | if (comments) {
73 | if (comments.length) {
74 | const formatted = comments.map(comment => formatWPComment(comment, thread));
75 | await saveComments(formatted);
76 | } else {
77 | const formatted = formatWPComment(comments, thread);
78 | await saveComments([formatted]);
79 | }
80 | }
81 | }
82 | }
83 |
84 | async function saveComment(post) {
85 | db = await dbPromise;
86 | const { comment, author } = post;
87 |
88 | if (!author[1]) {
89 | author[1] = 'Anonymous Guest';
90 | }
91 |
92 | try {
93 | await db.run(queries.create_user, author);
94 | const newUser = await db.get(queries.find_user, [author[0], author[1]]);
95 | if (newUser.id) comment.unshift(newUser.id); // push user_id to the front
96 | const res = await db.run(
97 | `INSERT INTO comment
98 | (user_id, slug, comment, reply_to, created_at, approved, rejected)
99 | VALUES (?,?,?,?,?,?,0)`,
100 | comment
101 | );
102 | return res.lastID;
103 | } catch (err) {
104 | console.error(`Error saving the comment for the slug ${comment[0]}:`, err);
105 | }
106 | }
107 |
108 | async function saveComments(posts) {
109 | for (let post of posts) {
110 | const newComment = await saveComment(post);
111 | post.new_id = newComment;
112 | }
113 |
114 | for (let post of posts) {
115 | const replies = posts.filter(p => p.comment[3] === post.id); // replies to current post
116 | if (replies) {
117 | for (let reply of replies) {
118 | const { id, new_id } = post;
119 | await db.run(`UPDATE comment SET reply_to = ? WHERE reply_to = ?`, [new_id, id]);
120 | }
121 | }
122 | }
123 | }
124 |
125 | function getDisqusComments(threads, comment) {
126 | const { author } = comment;
127 | const thread = threads.filter(thread => thread.$['dsq:id'] === comment.thread.$['dsq:id'])[0]
128 | .id;
129 | const reply_to = comment.parent ? comment.parent.$['dsq:id'] : null;
130 | const message = toMarkdown(comment.message.trim());
131 | const timestamp = comment.createdAt;
132 | const approved = comment.isDeleted === 'true' || comment.isSpam === 'true' ? 0 : 1;
133 |
134 | return {
135 | comment: [thread, message, reply_to, timestamp, approved],
136 | id: comment.$['dsq:id'],
137 | author: ['disqus', author.username, author.name, author.username, 0]
138 | };
139 | }
140 |
141 | async function parseDisqus(data) {
142 | const threads = data.disqus.thread;
143 | const posts = data.disqus.post.map(comment => getDisqusComments(threads, comment));
144 |
145 | await saveComments(posts);
146 | }
147 |
148 | // Main
149 | async function run() {
150 | try {
151 | const filePath = path.resolve(__dirname, '..', filename);
152 | const content = await readFile(filePath);
153 | const result = await parse(content);
154 |
155 | if (result.disqus) {
156 | parseDisqus(result);
157 | } else if (result.rss) {
158 | parseWP(result);
159 | }
160 | } catch (error) {
161 | console.error('Error parsing the file:', filename);
162 | console.error(error);
163 | }
164 | }
165 |
166 | run();
167 |
--------------------------------------------------------------------------------
/src/notify.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const countBy = require('lodash.countby');
3 | const queries = require('./db/queries');
4 |
5 | const { send_file } = require('./helper');
6 | const config = require('./config');
7 | const { plugins } = require('./plugins');
8 |
9 | function init(app, db, awaiting_moderation) {
10 | // push notification apps
11 | const notifier = [];
12 |
13 | // initialize notify plugins
14 | plugins.forEach(plugin => {
15 | if (typeof plugin.notify === 'function') {
16 | plugin.notify({
17 | notifier,
18 | page_url: config.get('page_url')
19 | });
20 | }
21 | });
22 |
23 | setInterval(() => {
24 | let bySlug;
25 | if (awaiting_moderation.length) {
26 | bySlug = countBy(awaiting_moderation, 'slug');
27 | next();
28 | awaiting_moderation.length = 0;
29 | }
30 | async function next(err) {
31 | const k = Object.keys(bySlug)[0];
32 | if (!k || err) return;
33 | try {
34 | const row = await db.get(queries.get_settings, 'notification');
35 | const cnt = bySlug[k];
36 | const msg = {
37 | message: `${cnt} new comment${
38 | cnt > 1 ? 's' : ''
39 | } on "${k}" are awaiting moderation.`,
40 | url: config.get('page_url').replace('%SLUG%', k),
41 | sound: !row.active ? 'pushover' : 'none'
42 | };
43 | delete bySlug[k];
44 | setTimeout(() => {
45 | notifier.forEach(f => f(msg, next));
46 | }, 1000);
47 | } catch (err) {
48 | console.error(err.message);
49 | }
50 | }
51 | }, config.get('notification_interval'));
52 |
53 | }
54 |
55 | module.exports = {
56 | init
57 | };
58 |
--------------------------------------------------------------------------------
/src/plugins.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const config = require('./config');
4 | const queries = require('./db/queries');
5 | const events = require('./events');
6 |
7 | const pluginConfig = config.get('plugins');
8 | const schnackHost = config.get('schnack_host');
9 | const plugins = [];
10 |
11 | module.exports = {
12 | loadPlugins({ db, app }) {
13 | // load plugins
14 | Object.keys(pluginConfig).forEach(pluginId => {
15 | const plugin = loadPlugin(pluginId, pluginConfig[pluginId]);
16 | if (typeof plugin === 'function') {
17 | // eslint-disable-next-line no-console
18 | console.log(`successfully loaded plugin ${pluginId}`);
19 | plugins.push(
20 | plugin({
21 | config: pluginConfig[pluginId],
22 | host: schnackHost,
23 | app,
24 | db,
25 | queries,
26 | events
27 | })
28 | );
29 | }
30 | });
31 | },
32 | plugins
33 | };
34 |
35 | function loadPlugin(pluginId, cfg) {
36 | if (fs.existsSync(path.join(__dirname, `./plugins/${pluginId}/index.js`))) {
37 | // local plugin
38 | return require(`./plugins/${pluginId}`);
39 | } else {
40 | // npm require (plugin need to be installed via npm first)
41 | try {
42 | const packageName = cfg.pkg || `@schnack/plugin-${pluginId}`;
43 | return require(packageName);
44 | } catch (err) {
45 | console.warn(`could not load plugin ${pluginId}`);
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/plugins/notify-webpush/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const webpush = require('web-push');
4 | const { sendString, error } = require('../../helper');
5 |
6 | module.exports = ({ config, host, app, db, queries, events }) => {
7 | if (!config.vapid_public_key) {
8 | // VAPID keys should only be generated only once.
9 | const vapidKeys = webpush.generateVAPIDKeys();
10 | config = {
11 | vapid_public_key: vapidKeys.publicKey,
12 | vapid_private_key: vapidKeys.privateKey
13 | };
14 | // eslint-disable-next-line no-console
15 | console.log(
16 | 'please insert the following keys into \nyour `notify-webpush` config section:\n',
17 | config
18 | );
19 | }
20 | return {
21 | notify({ notifier, page_url }) {
22 | webpush.setVapidDetails(host, config.vapid_public_key, config.vapid_private_key);
23 |
24 | notifier.push(async msg => {
25 | await db.each(
26 | queries.get_subscriptions,
27 | (err, row) => {
28 | if (err) return console.error(err);
29 |
30 | const subscription = {
31 | endpoint: row.endpoint,
32 | keys: {
33 | p256dh: row.publicKey,
34 | auth: row.auth
35 | }
36 | };
37 | webpush.sendNotification(
38 | subscription,
39 | JSON.stringify({
40 | title: 'schnack',
41 | message: msg.message,
42 | clickTarget: msg.url
43 | })
44 | );
45 | }
46 | );
47 | });
48 |
49 | app.use(
50 | '/push.js',
51 | sendString(
52 | fs
53 | .readFileSync(path.resolve(__dirname, '../../embed/push.js'), 'utf-8')
54 | .replace('%VAPID_PUBLIC_KEY%', config.vapid_public_key)
55 | .replace('%SCHNACK_HOST%', host),
56 | true
57 | )
58 | );
59 |
60 | // push notifications
61 | app.post('/subscribe', async (request, reply) => {
62 | const { endpoint, publicKey, auth } = request.body;
63 |
64 | try {
65 | await db.run(queries.subscribe, endpoint, publicKey, auth);
66 | reply.send({ status: 'ok' });
67 | } catch (err) {
68 | error(err, request, reply);
69 | }
70 | });
71 |
72 | app.post('/unsubscribe', async (request, reply) => {
73 | const { endpoint } = request.body;
74 |
75 | try {
76 | await db.run(queries.unsubscribe, endpoint);
77 | reply.send({ status: 'ok' });
78 | } catch (err) {
79 | error(err, request, reply);
80 | }
81 | });
82 | }
83 | };
84 | };
85 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | /* eslint no-console: "off" */
3 | const fs = require('fs');
4 | const path = require('path');
5 | const express = require('express');
6 | const app = express();
7 | const cors = require('cors');
8 | const bodyParser = require('body-parser');
9 | const moment = require('moment');
10 |
11 | const RSS = require('rss');
12 | const marked = require('marked');
13 | const insane = require('insane');
14 | const jst = require('jst');
15 |
16 | const dbHandler = require('./db');
17 | const queries = require('./db/queries');
18 | const auth = require('./auth');
19 | const { loadPlugins } = require('./plugins');
20 | const notify = require('./notify');
21 | const schnackEvents = require('./events');
22 | const {
23 | error,
24 | getUser,
25 | sendFile,
26 | isAdmin,
27 | checkOrigin,
28 | checkValidComment,
29 | getSchnackDomain
30 | } = require('./helper');
31 |
32 | const config = require('./config');
33 |
34 | const awaiting_moderation = [];
35 |
36 | const DEV_MODE = process.argv.includes('--dev') || config.get('dev');
37 |
38 | dbHandler
39 | .init()
40 | .then(db => run(db))
41 | .catch(err => console.error(err.message));
42 |
43 | async function run(db) {
44 | app.use(
45 | cors({
46 | credentials: true,
47 | origin: DEV_MODE ? '*' : checkOrigin
48 | })
49 | );
50 |
51 | app.use(bodyParser.json());
52 | app.use(bodyParser.urlencoded({ extended: true }));
53 |
54 | // init plugins
55 | loadPlugins({ db, app });
56 |
57 | // init session + passport middleware and auth routes
58 | auth.init(app, db, getSchnackDomain());
59 | // init push notification plugins
60 | notify.init(app, db, awaiting_moderation);
61 |
62 | // serve static js files
63 | app.use('/embed.js', sendFile(path.resolve(__dirname, '../build/embed.js'), false, DEV_MODE));
64 | app.use('/client.js', sendFile(path.resolve(__dirname, '../build/client.js'), false, DEV_MODE));
65 |
66 | app.get('/comments/:slug', async (request, reply) => {
67 | const { slug } = request.params;
68 | const user = getUser(request);
69 | const providers = user ? null : auth.providers;
70 |
71 | let query = queries.get_comments;
72 | let args = [slug, user ? user.id : -1];
73 |
74 | if (isAdmin(user)) {
75 | user.admin = true;
76 | query = queries.admin_get_comments;
77 | args.length = 1;
78 | }
79 |
80 | const date_format = config.get('date_format');
81 | try {
82 | const comments = await db.all(query, args);
83 | comments.forEach(c => {
84 | const m = moment.utc(c.created_at);
85 | c.created_at_s = date_format ? m.format(date_format) : m.fromNow();
86 | c.comment = insane(marked(c.comment.trim()));
87 | if (!c.author_url) {
88 | c.author_url = auth.getAuthorUrl(c);
89 | }
90 | });
91 | reply.send({ user, auth: providers, slug, comments });
92 | } catch (err) {
93 | error(err, request, reply);
94 | }
95 | });
96 |
97 | app.get('/signout', (request, reply) => {
98 | delete request.session.passport;
99 | reply.send({ status: 'ok' });
100 | });
101 |
102 | // POST new comment
103 | app.post('/comments/:slug', async (request, reply) => {
104 | const { slug } = request.params;
105 | const { comment, replyTo } = request.body;
106 | const user = getUser(request);
107 |
108 | if (!user) return error('access denied', request, reply, 403);
109 | try {
110 | await checkValidComment(db, slug, user.id, comment, replyTo);
111 | } catch (err) {
112 | return reply.send({ status: 'rejected', reason: err });
113 | }
114 | try {
115 | const stmt = await db.prepare(queries.insert);
116 | await stmt.bind([user.id, slug, comment, replyTo ? +replyTo : null]);
117 | await stmt.run();
118 | if (!user.blocked && !user.trusted) {
119 | awaiting_moderation.push({ slug });
120 | }
121 | schnackEvents.emit('new-comment', {
122 | user: user,
123 | slug,
124 | id: stmt.lastID,
125 | comment,
126 | replyTo
127 | });
128 | reply.send({ status: 'ok', id: stmt.lastID });
129 | } catch (err) {
130 | error(err, request, reply);
131 | }
132 | });
133 |
134 | // trust/block users or approve/reject comments
135 | app.post(
136 | /\/(?:comment\/(\d+)\/(approve|reject))|(?:user\/(\d+)\/(trust|block))/,
137 | async (request, reply) => {
138 | const user = getUser(request);
139 | if (!isAdmin(user)) return reply.status(403).send(request.params);
140 | const action = request.params[1] || request.params[3];
141 | const target_id = +(request.params[0] || request.params[2]);
142 | try {
143 | await db.run(queries[action], target_id);
144 | reply.send({ status: 'ok' });
145 | } catch (err) {
146 | error(err, request, reply);
147 | }
148 | }
149 | );
150 |
151 | app.get('/success', (request, reply) => {
152 | const schnackDomain = getSchnackDomain();
153 | reply.send(``);
157 | });
158 |
159 | if (DEV_MODE) {
160 | app.get('/', (request, reply) => {
161 | const testPage = jst.compile(
162 | fs.readFileSync(path.join(__dirname, '../test/index.html'), 'utf-8')
163 | );
164 | reply.send(
165 | testPage({
166 | protocol: config.get('ssl') ? 'https' : 'http',
167 | port: config.get('port')
168 | })
169 | );
170 | });
171 | app.use(express.static('test'));
172 | } else {
173 | app.get('/', (request, reply) => {
174 | reply.send({ test: 'ok' });
175 | });
176 | }
177 |
178 | app.get('/', (request, reply) => {
179 | reply.send({ test: 'ok' });
180 | });
181 |
182 | // rss feed of comments in need of moderation
183 | app.get('/feed', async (request, reply) => {
184 | const user = getUser(request);
185 | if (!isAdmin(user)) return reply.status(403).send({ error: 'Forbidden' });
186 | var feed = new RSS({
187 | title: 'Awaiting moderation',
188 | site_url: config.get('schnack_host')
189 | });
190 | try {
191 | await db.each(queries.awaiting_moderation, (err, row) => {
192 | if (err) console.error(err.message);
193 | feed.item({
194 | title: `New comment on "${row.slug}"`,
195 | description: `A new comment on "${row.slug}" is awaiting moderation`,
196 | url: row.slug + '/' + row.id,
197 | guid: row.slug + '/' + row.id,
198 | date: row.created_at
199 | });
200 | });
201 | reply.send(feed.xml({ indent: true }));
202 | } catch (err) {
203 | console.error(err);
204 | }
205 | });
206 |
207 | // for markdown preview
208 | app.post('/markdown', (request, reply) => {
209 | const { comment } = request.body;
210 | reply.send({ html: insane(marked(comment.trim())) });
211 | });
212 |
213 | // settings
214 | app.post('/setting/:property/:value', async (request, reply) => {
215 | const { property, value } = request.params;
216 | const user = getUser(request);
217 | if (!isAdmin(user)) return reply.status(403).send(request.params);
218 | const setting = value ? 1 : 0;
219 | try {
220 | await db.run(queries.set_settings, [property, setting]);
221 | reply.send({ status: 'ok' });
222 | } catch (err) {
223 | error(err, request, reply);
224 | }
225 | });
226 |
227 | if (DEV_MODE) {
228 | // create dev user for testing purposes
229 | await db.run(
230 | 'INSERT OR IGNORE INTO user (id,name,blocked,trusted,created_at) VALUES (1,"dev",0,1,datetime())'
231 | );
232 | }
233 |
234 | const configSsl = config.get('ssl');
235 | let server;
236 |
237 | function done() {
238 | console.log(`server listening on ${server.address().port}`);
239 | if (DEV_MODE) {
240 | console.log(`you can now try out schnack at \x1b[37mhttp${configSsl ? 's' : ''}://localhost:${server.address().port}\x1b[0m\n`);
241 | }
242 | }
243 |
244 | if (configSsl && configSsl.certificate_path) {
245 | const https = require('https');
246 | const fs = require('fs');
247 |
248 | const sslOptions = {
249 | key: fs.readFileSync(configSsl.certificate_key),
250 | cert: fs.readFileSync(configSsl.certificate_path),
251 | requestCert: false,
252 | rejectUnauthorized: false
253 | };
254 |
255 | server = https.createServer(sslOptions, app);
256 | server.listen(config.get('port'), done);
257 | } else {
258 | server = app.listen(config.get('port'), config.get('host'), err => {
259 | if (err) throw err;
260 | done();
261 | });
262 | }
263 | }
264 |
--------------------------------------------------------------------------------
/sw.js:
--------------------------------------------------------------------------------
1 | self.addEventListener('push', (event) => {
2 | if (!(self.Notification && self.Notification.permission === 'granted')) {
3 | return;
4 | }
5 |
6 | let data = {};
7 | if (event.data) {
8 | data = event.data.json();
9 | }
10 | const title = data.title;
11 | const message = data.message;
12 |
13 | self.clickTarget = data.clickTarget;
14 |
15 | event.waitUntil(self.registration.showNotification(title, {
16 | body: message,
17 | tag: 'schnack'
18 | }));
19 | });
20 |
21 | self.addEventListener('notificationclick', (event) => {
22 | event.notification.close();
23 |
24 | if(clients.openWindow){
25 | event.waitUntil(clients.openWindow(self.clickTarget));
26 | }
27 | });
28 |
--------------------------------------------------------------------------------
/test/disqus.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 | webkid
6 | General
7 | true
8 |
9 |
10 | foo
11 | webkid
12 |
13 | http://localhost:3000/foo/
14 | Welcome
15 |
16 | 2014-09-26T20:11:04Z
17 |
18 | info@webkid.io
19 | webkid
20 | false
21 | webkid
22 |
23 | 127.0.0.1
24 | false
25 | false
26 |
27 |
28 | bar
29 | webkid
30 |
31 | http://localhost:3000/bar/
32 | Bar!
33 |
34 | 2014-09-26T20:11:04Z
35 |
36 | info@webkid.io
37 | webkid
38 | false
39 | webkid
40 |
41 | 127.0.0.1
42 | false
43 | false
44 |
45 |
46 |
47 |
48 | Hey, wow!// Amazing post.
Check example .
]]>
49 |
50 | 2014-11-01T15:06:24Z
51 | false
52 | false
53 |
54 | moriz@webkid.io
55 | Moritz
56 | false
57 | moritz
58 |
59 | 127.0.0.1
60 |
61 |
62 |
63 |
64 |
65 | Hi Moritz,thanks for reading!
]]>
66 |
67 | 2014-11-01T16:59:29Z
68 | false
69 | false
70 |
71 | christopher@webkid.io
72 | Christopher
73 | false
74 | christopher
75 |
76 | 127.0.0.1
77 |
78 |
79 |
80 |
81 |
82 |
83 | Hi Christopher!Thanks!
]]>
84 |
85 | 2014-11-13T09:03:26Z
86 | false
87 | false
88 |
89 | moriz@webkid.io
90 | Moritz
91 | false
92 | moritz
93 |
94 | 127.0.0.1
95 |
96 |
97 |
98 |
99 |
100 |
101 | Bar is the best.]]>
102 |
103 | 2014-11-01T15:06:24Z
104 | false
105 | false
106 |
107 | moriz@webkid.io
108 | Moritz
109 | false
110 | moritz
111 |
112 | 127.0.0.1
113 |
114 |
115 |
116 |
117 |
118 | Nice! :)]]>
119 |
120 | 2014-11-13T10:40:03Z
121 | false
122 | false
123 |
124 | christopher@webkid.io
125 | Christopher
126 | false
127 | christopher
128 |
129 | 127.0.0.1
130 |
131 |
132 |
133 |
134 |
--------------------------------------------------------------------------------
/test/fonts/schnack.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schn4ck/schnack/e928c14d9b4b3d4e1d1fd9d2bdc3d1e671ae0e49/test/fonts/schnack.eot
--------------------------------------------------------------------------------
/test/fonts/schnack.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Copyright (C) 2018 by original authors @ fontello.com
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/test/fonts/schnack.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schn4ck/schnack/e928c14d9b4b3d4e1d1fd9d2bdc3d1e671ae0e49/test/fonts/schnack.ttf
--------------------------------------------------------------------------------
/test/fonts/schnack.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schn4ck/schnack/e928c14d9b4b3d4e1d1fd9d2bdc3d1e671ae0e49/test/fonts/schnack.woff
--------------------------------------------------------------------------------
/test/fonts/schnack.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/schn4ck/schnack/e928c14d9b4b3d4e1d1fd9d2bdc3d1e671ae0e49/test/fonts/schnack.woff2
--------------------------------------------------------------------------------
/test/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | schnack.js test page
6 |
9 |
10 |
11 |
29 |
30 |
31 |
40 |
41 |
42 |
schnack! test page
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/test/schnack-icons.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'schnack';
3 | src: url('fonts/schnack.eot?93389249');
4 | src: url('fonts/schnack.eot?93389249#iefix') format('embedded-opentype'),
5 | url('fonts/schnack.svg?93389249#schnack') format('svg');
6 | font-weight: normal;
7 | font-style: normal;
8 | }
9 | @font-face {
10 | font-family: 'schnack';
11 | src: url('data:application/octet-stream;base64,d09GRgABAAAAABXsAA8AAAAAI0wAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABWAAAADsAAABUIIslek9TLzIAAAGUAAAAQwAAAFY+IEmvY21hcAAAAdgAAACPAAACLjJr1XBjdnQgAAACaAAAABMAAAAgBy//BGZwZ20AAAJ8AAAFkAAAC3CKkZBZZ2FzcAAACAwAAAAIAAAACAAAABBnbHlmAAAIFAAACq4AAA8APB5T4WhlYWQAABLEAAAAMwAAADYTR/rVaGhlYQAAEvgAAAAcAAAAJAc7A2JobXR4AAATFAAAABIAAAA8OlX//2xvY2EAABMoAAAAIAAAACAaTh2wbWF4cAAAE0gAAAAgAAAAIAFUDBtuYW1lAAATaAAAAX8AAALBGsqPinBvc3QAABToAAAAhwAAALXJJXp4cHJlcAAAFXAAAAB6AAAAhuVBK7x4nGNgZGBg4GIwYLBjYHJx8wlh4MtJLMljkGJgYYAAkDwymzEnMz2RgQPGA8qxgGkOIGaDiAIAJjsFSAB4nGNgZH7COIGBlYGBqYppDwMDQw+EZnzAYMjIBBRlYGVmwAoC0lxTGBxeMLzgZw76n8UQxVzPMA0ozAiSAwAMLQxNAHic7ZHbDcIwEATHSQivEDqgAsqgIL6ojZK2i7DnbBmcND75LFvWDnAARvM0E7QPjaq3p63PRy59PvHyfnZvDFp03zYQe3c1nz16H3xn8sszR06cff/Kwo3VhzP/Wvr6zW6tFHcqcQWniEIZUihLCmVPwWmj4NxRsAEU7AKFsqpgPyjU7xTszH53WH/QFSXaAHicY2BAAxIQyFz/PwuEARQuBDcAeJytVml300YUHXlJnIQsJQstamHExGmwRiZswYAJQbJjIF2crZWgixQ76b7xid/gX/Nk2nPoN35a7xsvJJC053Cak6N3583VzNtlElqS2AvrkZSbL8XU1iaN7DwJ6YZNy1F8KDt7IWWKyd8FURCtltq3HYdERCJQta6wRBD7HlmaZHzoUUbLtqRXTcotPekuW+NBvVXffho6yrE7oaRmM3RoPbIlVRhVokimPVLSpmWo+itJK7y/wsxXzVDCiE4iabwZxtBI3htntMpoNbbjKIpsstwoUiSa4UEUeZTVEufkigkMygfNkPLKpxHlw/yIrNijnFawS7bT/L4vead3OT+xX29RtuRAH8iO7ODsdCVfhFtbYdy0k+0oVBF213dCbNnsVP9mj/KaRgO3KzK90IxgqXyFECs/ocz+IVktnE/5kkejWrKRE0HrZU7sSz6B1uOIKXHNGFnQ3dEJEdT9kjMM9pg+Hvzx3imWCxMCeBzLekclnAgTKWFzNEnaMHJgJWWLKqn1rpg45XVaxFvCfu3a0ZfOaONQd2I8Ww8dWzlRyfFoUqeZTJ3aSc2jKQ2ilHQmeMyvAyg/oklebWM1iZVH0zhmxoREIgIt3EtTQSw7saQpBM2jGb25G6a5di1apMkD9dyj9/TmVri501PaDvSzRn9Wp2I62AvT6WnkL/Fp2uUiRen66Rl+TOJB1gIykS02w5SDB2/9DtLL15YchdcG2O7t8yuofdZE8KQB+xvQHk/VKQlMhZhViFZAYq1rWZbJ1awWqcjUd0OaVr6s0wSKchwXx76Mcf1fMzOWmBK+34nTsyMuPXPtSwjTHHybdT2a16nFcgFxZnlOp1mW7+s0x/IDneZZntfpCEtbp6MsP9RpgeVHOh1jeUELmnTfwZCLMOQCDpAwhKUDQ1hegiEsFQxhuQhDWBZhCMslGMLyYxjCchmGsLysZdXUU0nj2plYBmxCYGKOHrnMReVqKrlUQrtoVGpDnhJulVQUz6p/ZaBePPKGObAWSJfIml8xzpWPRuX41hUtbxo7V8Cx6m8fjvY58VLWi4U/Bf/V1lQlvWLNw5Or8BuGnmwnqjapeHRNl89VPbr+X1RUWAv0G0iFWCjKsmxwZyKEjzqdhmqglUPMbMw8tOt1y5qfw/03MUIWUP34NxQaC9yDTllJWe3grNXX27LcO4NyOBMsSTE38/pW+CIjs9J+kVnKno98HnAFjEpl2GoDrRW82ScxD5neJM8EcVtRNkja2M4EiQ0c84B5850EJmHqqg3kTuGGDfgFYW7BeSdconqjLIfuRezzKKT8W6fiRPaoaIzAs9kbYa/vQspvcQwkNPmlfgxUFaGpGDUV0DRSbqgGX8bZum1Cxg70Iyp2w7Ks4sPHFveVkm0ZhHykiNWjo5/WXqJOqtx+ZhSX752+BcEgNTF/e990cZDKu1rJMkdtA1O3GpVT15pD41WH6uZR9b3j7BM5a5puuiceel/TqtvBxVwssPZtDtJSJhfU9WGFDaLLxaVQ6mU0Se+4BxgWGNDvUIqN/6v62HyeK1WF0XEk307Ut9HnYAz8D9h/R/UD0Pdj6HINLs/3mhOfbvThbJmuohfrp+g3MGutuVm6BtzQdAPiIUetjrjKDXynBnF6pLkc6SHgY90V4gHAJoDF4BPdtYzmUwCj+Yw5PsDnzGHQZA6DLeYw2GbOGsAOcxjsMofBHnMYfMGcdYAvmcMgZA6DiDkMnjAnAHjKHAZfMYfB18xh8A1z7gN8yxwGMXMYJMxhsK/p1jDMLV7QXaC2QVWgA1NPWNzD4lBTZcj+jheG/b1BzP7BIKb+qOn2kPoTLwz1Z4OY+otBTP1V050h9TdeGOrvBjH1D4OY+ky/GMtlBr+MfJcKB5RdbD7n74n3D9vFQLkAAQAB//8AD3ictVdbbBzVGT7/OXPd3dndmd2Z2ft6d7y7WV/W673GsR2v7cROnNhOnE3iJI5xCImdOMYkBFLRpEgNoEqhUEEFaYvKpUgloQ+0Ki/QiyiqkEol+tKHCpBAohfxUGhFeaB40392HZRQql6k7qzmcs6cM+f7L9//HUIJufYmu48tkiwZJrFamAEFukqAwCoh5HhHR8dwx3Cl3MXxeicYZvPQ/aIbrGQmXVZLlWplCPBUbd2YhijgwRf8oqCrftMQ7Pcy6UEo2W/Qq9StBjNbSwPxivnwIw9F48GI1GYqmosWqvW4o0fTNqjuxpgkFcs8COFIIppIxOMpGmeMY5zfGx9+a2k525GDfWo66NcF0y27JJocWcwokwMbhwJ+zR7a+EVvleNFh5UZ/OtgJuVwEBvnbxDnPNlARmycBCEiTAqriJgez2azI9mRUiHbxJkDe83lklYscIhCFDwgNE+tvyhk0k3EqSSe0QiZdLFgGyYK/iZ8N8BmGgtkxkvnux6FiETdolc1fFk5nC5Wk+3uFlBNgZcEEHvLsrMJVHXJTiWShQCTKOfXotvg6t5c46riEjyyS1XdUd4fSox2dHtbWE2/XxJLJdhcLMstqIWY5lREt4KOQ7yEsG+wKtFIiqRqyVR7zFB4KhKAcRfA1iiQLZ95ucdHeaMzVamqqWoOmtBMG4cfPS7GoIkpY7eWMiL4+vsb78/29QG7e+Eh7uj2/QyeunSp1TD3CD2CDfWzv+rf1HhT3TfwyJ7n7z7iO66N7f/KBfhEktdb9KVmy+G77GVCc62vMI30kK21kVxXR4LyHB8NGRr63AFA2TjBhlWCj6uEUbZK1oOUQB0BkVl7kp16tF1NZgQ+1Ikuuh6jRrFQRV9evw4iInRstXWuFMxKVRAr7JVsddPmxtNP3udI3X9sKNerSsG4f7Qz25mth554X6hWn944WtIH++hLfVnz4rl7nq3TadZubWK8p1QeoTobOJrv/qmgmbtL1T53dCQtIyRuHZdFFDJJFskZ8nZNGR3qYxIP49sGKb91xwvyrtlaD+ElBCeJ0ioRibiqyE5GELZAYMEFAqVCHS8CneUY3tOd4R0vOHBY6XPD0DCU3favB0Nz7H/9tQMHaqkTS9NTQFZOLZ05ceaW+cP7pxanj4/Usu1WMhFxSEQBxc37O6GAKdAye7pUSdk0IbQOzBkrKaBbkhnss5I9YLsk3UoydIvNDYZZKdruQBaxKUaAQqVcst/Acc0BSXsm3W8UWcHw40xpHEW/qehOv9+pK+FMCsiE5g2b2XRbJulrc4oxyTI2GZYUEx1JLZkJd2bNsFebYMAJkYWvds/s7SiMFpKhsMBB4/z3r1TNbOpyhFNzejmYiLf7yleffSKcShcwz3SYcymx8YiiFKeK70+fmejvT7clQh43z3iKP7y4lUiiLd3fP3F6F/Wb7dtHlTlFsRKmJGwdbzf9a2vT57f1BkTBxYAxSfL1TJyfBhOnC0XGY0E7lK/9nj3DapitW2rDIsY8MAorEgDcLmMvzxF+AROAcXXCcWyfAEiIE0BikXAoiLSnehSX0yGLJAXtjqYz0IiqH42nFstWudQPZQstGAVLL6qJpE3d1UqRVXKxtqHuZ06fjuy9ZUe+uLTUeKl7qC2WY7V4JLd2T/cQlB8Yj16uVCZHH2i8PtRNH8hF4hjWPK73J+y37ALxkhiZJnPkFByqOboAuEWQFDreitFDhKccJvMKwfDjYBkDTQaHvOIEmeB/WXVTF3rVJa5o4BEV0aOsEEUSFWmZSEyU2DJh7HbbOAKQBSIQ0SOIC14kbBfdjmngxE/s/edPEHCQlf9waqdM7cnnPz/5/3XpmFPd84d37dLUw6fmT+2a2zV36OCB2f379tb3zEzvHNtSGyqX8jlTV2NaLJDyITeDgXklYvI0K0+pWPAV0PLNZ291M/g2g50sNqXhtVTph80wiHnYLFY6luF+EAzTStq39kMhVcKgsHQMBr1YFgtmi+QxE22Wh1/KMu92OfS4xItOv6Nz82sQavzhtaNXtj344LYr26LRQsRIWDzn0LU7BU0OROOpUJynkoNeihnhkCKmwZrMT07m/xw3NMHQk4kSczl8GZqKRdweFouGI6ZAIdH4U+POxntj41+Hhy+Nj63NePJWd48hu7yJ7gEN2UwSOAZiKNxbDXpdQdctmYBP4t4NBCbtyfu93rLPy4R4SAk6dIZU2+Tb0yxInETHPNpYK9uVkBG6gH28wPgFCQRkujpeBNgnchQfJ0wjHjNSZsrnU/1+r4zlw9dmGppuldC4Cd3OIwtta5mFlvSxqcc29Dnoe+f0Hefm519ufPTW49/50rmzz98ZyGiJRPtcwrLaWPCdOxp/++797829fNe3L//uytlzP/KqiZ6u9kTC6s63JZrrvfYzXO+9xEM6yUZyV83JgFAPLovYCeTH6M4Qkec48RixKx6BFax2twvAYX3n6BHC82wWw8zFY7xusN/kRYzXf/PqgZpT1QrWQNxUJT7S6TPaqqzJ1E2hVoaWjsHntkwOmrptCKOp32Z3sVDMpJGOPVCsFq/eA0/dU7U29G//2vZKtwU/PLhleGPfmcavz/RtHN5ysNNhmfNBS3J2dCwp2pIpnajXZqg0NtYIKWZQ03UtGHQyn5pOJx3RvJ7N6vmoI5lOqz2K2608ecHrvTB8I88oxE1MEiAJUqzl2+KaivUJxnlgBBjMEUoJljhCMLtCQSDBRCih+wTOrk1Ckw5vTBce/CakbT708YVmyoABH90U5HBs7dEZCrfdd/nkiRNAv7V8Ymbt2m3sws0h+3Zj7f5bKZ05eaqxePLkqccuHltrzKxrGuTzH7A6cRC9pkmigM4hW1CrkOOGRpsZbSZ4Xwr/CfhooB+GG5/MNz5s/OVw4+9QY/Xnnnu18ewbb8Dcq+ta7susB+cKEbPmR5xbQ4YicijhcD6fBjbCGERBBLEKFbCFGkK1Mp6mZGcDbfFPG29/UKTRSuOQy1nQAwGn29R8kYCT/rFtVFE/bbxb/pBGXWtVxdVV0FQXNXfLgbCxrs1epB9iRqVrFnIZ6kcR9SPf0o83CTB/S0baX9Zl0Aut3UK8tYYX95y/9/XG43CiXt9V7E91RLw+ycdtf6yn+02YgpGfh8NdvbneqpXVcgEM4WvXrn2PnWIzqJyWoacllHy7B6ksdQFDTUpZFIUhN74uhq53cTd2feGAAwdak1UcIPEyL9mMLspMFtmKC4igUF6yK63MYf2V2QImE9A6hhfM2ruF6+Jr4KbRnGznHcdkji3fNAn54jn+149j3bBsJba0eOzokYX5uYMH9u6ZWp5exnqQb08GTTIJO5tKTFjfsthHFARzfePW2qRlxOYOpnmU0+XPuooVTI2m+G8Jr0y6pdPEQhyb7R0OqodyyUreeKnAjxd27Mm7Va+p6i43mtnj7yruP72Y74yFfMyMRjvSlWhXMCrSYMLK93QWYwO0r1wYFWSeUbdLV02v6s7v2bFwaWcqEExPDG9IH6mNHRkfunWKzYDDZUhOjx5OZnv7rN3dZydvHRgzozxziLI8JYgcj3QuG6E81RyCwIsUS7YoBs3hRG6kp9TZ15tNhnWPUzJcDniyL5vt6+6Y+viDnQcvXjy484OPJ2f/AU4UXO0AAHicY2BkYGAA4m5bn2vx/DZfGbiZXwBFGG4o3E6H0f///1/M/IK5HsjlYGACiQIAc6QODQB4nGNgZGBgDvqfBSRf/P8PIhmAIiiAHwCL1AWkeJxjfsHAwEwOXvr/PwCthw+BAAAAAAAAAHQA7AFCAa4CxAMcBGYEvgVQBawF0gYQBkwHgAABAAAADwBzAAUAAAAAAAIAJgA2AHMAAACmC3AAAAAAeJx1kM1qwkAUhU/qT6lCFy0UuptVqxTiDwgiXQiCQnd1IRS6iTEm0ZiRySj4En2HPkhfpc/Sk3gpVWiGYb577pk7NxfADb7h4Pj1uI/s4JLRkS/Iz8Il8ki4TH4RrqCOV+Eq9XfhGp7gC9dxiw9WcMpXjFb4FHZw7dSFL8j3wiXyo3CZ3BOu4M4ZCVepvwnXMHNC4ToenK+R3h5MHEZWNUZN1W13+mp+UJpSnHqJ8nY20iZTQ7XUqQ2SRLu+3mR+lHr+ehqEu8QzEskxC0wW61R13LYokyANjGeDRV4524dda5dqafRGjaWm2hq9CnzrRtZuB63W37c4So0tDjCIESKChUKDapNnF2100CfN6VB0Hl0xUnhIqHjY8UZUZDLGQ+4lo5RqQEdCdjl+jQ3zPp35TR9rTJkPeTthbM5yp9GMztwRF3UVO3LZ16lnQk9a+Lzi5cVvzxn2fKdL1bKzvDtTdKMwPutTcQ55bkXFp+4W07BUB2hx/fNfP5FVf60AeJxtwUsWwiAMAEBiC/1obS/SQ/GJFYuCSdDn7V24dUYd1M+o/pvhAA20oMFABz0MMMIRTjDBGWZYVGdLofxCQ3hDLwNXx56iw07eUQTJbFGu1S0X69HlvK/8rJYwTFtJlVcfyScMDTGbgAkFtUvZ71qosrQYomjCkj793bLkkB9KfQEhiirBAHicY/DewXAiKGIjI2Nf5AbGnRwMHAzJBRsZWJ02MTAyaIEYm7mYGDkgLD4GMIvNaRfTAaA0J5DN7rSLwQHCZmZw2ajC2BEYscGhI2Ijc4rLRjUQbxdHAwMji0NHckgESEkkEGzmYWLk0drB+L91A0vvRiYGFwAMdiP0AAA=') format('woff'),
12 | url('data:application/octet-stream;base64,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') format('truetype');
13 | }
14 | /* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
15 | /* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
16 | /*
17 | @media screen and (-webkit-min-device-pixel-ratio:0) {
18 | @font-face {
19 | font-family: 'schnack';
20 | src: url('fonts/schnack.svg?93389249#schnack') format('svg');
21 | }
22 | }
23 | */
24 |
25 | [class^="schnack-icon-"]:before, [class*=" schnack-icon-"]:before {
26 | font-family: "schnack";
27 | font-style: normal;
28 | font-weight: normal;
29 | speak: none;
30 |
31 | display: inline-block;
32 | text-decoration: inherit;
33 | width: 1em;
34 | margin-right: .2em;
35 | text-align: center;
36 | /* opacity: .8; */
37 |
38 | /* For safety - reset parent styles, that can break glyph codes*/
39 | font-variant: normal;
40 | text-transform: none;
41 |
42 | /* fix buttons height, for twitter bootstrap */
43 | line-height: 1em;
44 |
45 | /* Animation center compensation - margins should be symmetric */
46 | /* remove if not needed */
47 | margin-left: .2em;
48 |
49 | /* you can be more comfortable with increased icons size */
50 | /* font-size: 120%; */
51 |
52 | /* Uncomment for 3D effect */
53 | /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
54 | }
55 | .schnack-icon-approve:before { content: '\e800'; } /* '' */
56 | .schnack-icon-reject:before { content: '\e801'; } /* '' */
57 | .schnack-icon-subscribe:before { content: '\e802'; } /* '' */
58 | .schnack-icon-twitter:before { content: '\e803'; } /* '' */
59 | .schnack-icon-github:before { content: '\e804'; } /* '' */
60 | .schnack-icon-facebook-squared:before { content: '\e805'; } /* '' */
61 | .schnack-icon-gplus-circled:before { content: '\e806'; } /* '' */
62 | .schnack-icon-rss:before { content: '\e807'; } /* '' */
63 | .schnack-icon-delete:before { content: '\e808'; } /* '' */
64 | .schnack-icon-block:before { content: '\e809'; } /* '' */
65 | .schnack-icon-trust:before { content: '\e80a'; } /* '' */
66 | .schnack-icon-edit:before { content: '\e80b'; } /* '' */
67 | .schnack-icon-reply:before { content: '\e80c'; } /* '' */
68 | .schnack-icon-mastodon:before { content: '\e80f'; } /* '' */
69 |
--------------------------------------------------------------------------------
/test/schnack.css:
--------------------------------------------------------------------------------
1 | ul.schnack-comments {
2 | margin: 1em 0;
3 | padding: 0;
4 | }
5 | li.schnack-comment {
6 | list-style: none;
7 | margin: 0 0 1.5em;
8 | padding: 0;
9 | }
10 | .schnack-body {
11 | position: relative;
12 | margin: 10px 0 0;
13 | padding: 10px 10px;
14 | border-left: 4px solid #cecece;
15 | margin-bottom: 10px;
16 | }
17 |
18 | textarea.schnack-body {
19 | border: 1px solid #cecece;
20 | padding: 10px;
21 | width: 300px;
22 | height: 150px;
23 |
24 | }
25 |
26 | .schnack-date:before { content: "("; }
27 | .schnack-date:after { content: ")"; }
28 |
29 | .schnack-body p {
30 | margin: 0;
31 | }
32 |
33 | button.schnack-action {
34 | border:0;
35 | background: none;
36 | cursor: pointer;
37 | padding:0;
38 | opacity: 0.5;
39 | }
40 |
41 | .schnack-comment .schnack-form,
42 | .schnack-comment .schnack-comments {
43 | margin-left: 2em;
44 | }
45 |
46 |
47 | button.schnack-action:hover {
48 | color: #c00;
49 | opacity: 1;
50 | }
51 |
52 | button.schnack-action span {
53 | display: none;
54 | }
55 |
56 | li.schnack-comment.schnack-highlight {
57 | animation: flash 7s 1;
58 | }
59 |
60 | @keyframes flash {
61 | 0% { background-color: rgba(255,255,100,0.3); }
62 | 100% { background-color: rgba(255,255,255,0); }
63 | }
64 |
--------------------------------------------------------------------------------