├── .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 \n \n
\n "),e+='\n
\n '+(null==(t=n.partials.LoginStatus.replace("%USER%",n.user.name))?"":t)+'\n
\n
\n
\n \n \n
\n \n  \n  \n \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)+'\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 \n \n
\n "),e+='\n
\n '+(null==(t=n.partials.LoginStatus.replace("%USER%",n.user.name))?"":t)+'\n
\n
\n
\n \n \n
\n \n  \n  \n \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)+'\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 | 5 | 6 |
7 | <% } %> 8 |
9 | <%= data.partials.LoginStatus.replace('%USER%', data.user.name) %> 10 |
11 |
12 |
13 | 14 | 15 |
16 | 17 |   18 |   19 | 20 |
21 |
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 : '' %> 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 | Schnack logo 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,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') 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 | --------------------------------------------------------------------------------