├── .gitignore ├── icons ├── 16.png ├── 48.png └── 128.png ├── docs ├── admin-view.png ├── screenshot.png ├── user-view.png ├── dev.md ├── filters.md └── captcha.md ├── bin-exclude.txt ├── .vscode └── settings.json ├── popup.css ├── options.css ├── popup.html ├── popup.js ├── options.html ├── package.json ├── tools └── gecko.id.js ├── src ├── config.js └── webext.js ├── manifest.json ├── options.js ├── README.md └── background.js /.gitignore: -------------------------------------------------------------------------------- 1 | xpi/ 2 | bin/ 3 | -------------------------------------------------------------------------------- /icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comntr/webext/HEAD/icons/16.png -------------------------------------------------------------------------------- /icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comntr/webext/HEAD/icons/48.png -------------------------------------------------------------------------------- /icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comntr/webext/HEAD/icons/128.png -------------------------------------------------------------------------------- /docs/admin-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comntr/webext/HEAD/docs/admin-view.png -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comntr/webext/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /docs/user-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comntr/webext/HEAD/docs/user-view.png -------------------------------------------------------------------------------- /bin-exclude.txt: -------------------------------------------------------------------------------- 1 | bin/ 2 | xpi/ 3 | docs/ 4 | tools/ 5 | package.json 6 | bin-exclude.txt 7 | *.md 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.background": "#f806", 4 | "titleBar.activeBackground": "#194A02", 5 | "titleBar.activeForeground": "#EFFEE8" 6 | } 7 | } -------------------------------------------------------------------------------- /popup.css: -------------------------------------------------------------------------------- 1 | html, body, iframe { 2 | padding: 0; 3 | margin: 0; 4 | border: none; 5 | overflow: hidden; 6 | width: 300pt; 7 | height: 200pt; 8 | } 9 | 10 | html.mobile, 11 | html.mobile > body, 12 | html.mobile > body > iframe { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /options.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Ubuntu, Arial, sans-serif; 3 | font-size: 14pt; 4 | width: 100%; 5 | margin: 0; 6 | } 7 | 8 | #config { 9 | width: 100%; 10 | } 11 | 12 | #config td:first-child { 13 | font-weight: bold; 14 | width: 10em; 15 | } 16 | 17 | #config tr:nth-child(2n+1) { 18 | background: #ccf; 19 | } 20 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Comntr 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | setTimeout(async () => { 2 | let tab = await getCurrentTab(); 3 | let srv = await gConfigProps.htmlServerURL.get(); 4 | let params = await gConfigProps.extraUrlParams.get(); 5 | let iframe = document.querySelector('body > iframe'); 6 | iframe.src = srv + '?' + params + '#' + tab.url; 7 | 8 | if ('orientation' in window) 9 | document.body.parentElement.classList.add('mobile'); 10 | }); 11 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | comntr.io config 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "bin": "rm -rf bin; mkdir bin; rsync -a * bin --exclude-from bin-exclude.txt", 4 | "xpi-prep": "rm -rf xpi; mkdir xpi; npm run bin", 5 | "xpi-zip": "cd bin; zip -0r ../xpi/comntr.xpi *; cd ..", 6 | "xpi-gecko-id": "node tools/gecko.id bin/manifest.json", 7 | "xpi": "npm run xpi-prep; npm run xpi-gecko-id; npm run xpi-zip", 8 | "xpi-prod": "npm run xpi-prep; npm run xpi-zip", 9 | "adb-push": "npm run xpi; adb push xpi/comntr.xpi /mnt/sdcard/WebExt/" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tools/gecko.id.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | let filepath = process.argv[2]; 4 | let textdata = fs.readFileSync(filepath, 'utf8'); 5 | let json = JSON.parse(textdata); 6 | 7 | extend(json, { 8 | "browser_specific_settings": { 9 | "gecko": { 10 | "id": "webext@comntr.io" 11 | } 12 | } 13 | }); 14 | 15 | let textdata2 = JSON.stringify(json, null, 2); 16 | fs.writeFileSync(filepath, textdata2, 'utf8'); 17 | 18 | function extend(res, src) { 19 | for (let key in src) { 20 | if (res[key] === src[key]) 21 | continue; 22 | 23 | if (key in res) { 24 | extend(res[key], src[key]); 25 | } else { 26 | res[key] = src[key]; 27 | } 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | gConfigProps = { 2 | dataServerURL: new StorageProp( 3 | 'srv.data', 4 | 'https://comntr.live:42751'), 5 | htmlServerURL: new StorageProp( 6 | 'srv.html', 7 | 'https://comntr.github.io'), 8 | extraUrlParams: new StorageProp( 9 | 'url.params', 10 | 'ext=1&tag=webext&filter=d33682eae71e1b3fc36b552041c559833d2241b5'), 11 | iconColorFetching: new StorageProp( 12 | 'icon.fetching', 13 | '#888'), 14 | iconColorError: new StorageProp( 15 | 'icon.error', 16 | '#c00'), 17 | iconColorNoComments: new StorageProp( 18 | 'icon.0comments', 19 | '#840'), 20 | iconColorHasComments: new StorageProp( 21 | 'icon.ncomments', 22 | '#0c0'), 23 | }; 24 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Comntr", 3 | "description": "Post comments to any URL.", 4 | "version": "1.5", 5 | "manifest_version": 2, 6 | "icons": { 7 | "128": "icons/128.png", 8 | "48": "icons/48.png", 9 | "16": "icons/16.png" 10 | }, 11 | "options_ui": { 12 | "page": "options.html", 13 | "open_in_tab": true 14 | }, 15 | "browser_action": { 16 | "default_title": "Comntr" 17 | }, 18 | "background": { 19 | "persistent": false, 20 | "scripts": [ 21 | "src/webext.js", 22 | "src/config.js", 23 | "background.js" 24 | ] 25 | }, 26 | "permissions": [ 27 | "tabs", 28 | "https://comntr.live:*/*", 29 | "contextMenus", 30 | "storage" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | const $ = selector => document.querySelector(selector); 2 | const $$ = selector => document.querySelectorAll(selector); 3 | 4 | const status = { 5 | set(...args) { 6 | log(...args); 7 | $('#status').textContent = args.join(' '); 8 | } 9 | }; 10 | 11 | document.addEventListener('DOMContentLoaded', () => { 12 | log('DOMContentLoaded'); 13 | loadConfig(); 14 | $('#save').onclick = () => saveConfig(); 15 | }); 16 | 17 | async function saveConfig() { 18 | status.set('Collecting config values.'); 19 | let trows = $$('#config tr'); 20 | 21 | for (let tr of trows) { 22 | let td = tr.querySelector('td:last-child'); 23 | let name = tr.getAttribute('prop'); 24 | let prop = gConfigProps[name]; 25 | let newValue = td.textContent; 26 | let value = await prop.get(); 27 | 28 | if (newValue != value) { 29 | log.i(prop.name, '=', newValue); 30 | await prop.set(newValue); 31 | } 32 | } 33 | 34 | status.set('Config saved.'); 35 | } 36 | 37 | async function loadConfig() { 38 | status.set('Loading config.'); 39 | let tbody = $('#config tbody'); 40 | 41 | for (let name in gConfigProps) { 42 | let prop = gConfigProps[name]; 43 | let value = await prop.get(); 44 | log.i(prop.name, ':', value); 45 | tbody.innerHTML += ` 46 | 47 | ${prop.name} 48 | ${value} 49 | `; 50 | } 51 | 52 | status.set('Config loaded.'); 53 | } 54 | -------------------------------------------------------------------------------- /docs/dev.md: -------------------------------------------------------------------------------- 1 | # Relevant Repos 2 | 3 | - [webext](comntr/webext) is the browser extension. It's just a shell for the main UI. 4 | - [comntr.github.io](comntr/comntr.github.io) is the main UI. Most of the activity happens here. The extension renders this UI in an iframe when the user clicks on the extension icon. 5 | - [captcha](comntr/captcha) is the basic captcha service. 6 | - [http-server](comntr/http-server) is the main data server. It stores the comments. 7 | 8 | # How to debug the extension 9 | 10 | - Chrome can load the `comntr/webext` repo dir as an unpacked extension. 11 | - `npm run xpi` makes a `xpi/comntr.xpi` zip file that can be installed on Firefox desktop. 12 | - `npm run xpi-prod` makes a `xpi/comntr.xpi` zip file that can be published to `addons.mozilla.org`. 13 | - `npm run adb-push` creates a zip file on the connected Android device. It can be opened on Firefox Android at the `file:///mnt/sdcard/WebExt/comntr.xpi` URL. 14 | 15 | # How to create your own comntr server 16 | 17 | 1. Get a VPS. I used a DigitalOcean VPS with a LetsEncrypt cert. 18 | 1. Register your own domain: `foobar.com`. 19 | 1. Look for `comntr.live` in sources and use `foobar.com` instead. 20 | 1. Start the http server: 21 | 1. Push the http server bits to your VPS: 22 | ```bash 23 | cd comntr/http-server 24 | npm run ssh-push 25 | ``` 26 | 1. SSH to the VPS and start the server: 27 | ```bash 28 | ssh root@foobar.com 29 | cd ~/comntr.io 30 | npm start & 31 | disown 32 | exit 33 | ``` 34 | 1. Check that the server is available at `https://foobar.com:42751/`. 35 | 1. Start the captcha service: 36 | 1. Push the captcha service bits to your VPS: 37 | ```bash 38 | cd comntr/captcha 39 | npm run rsync 40 | ``` 41 | 1. SSH to the VPS and start the capctah service: 42 | ```bash 43 | ssh root@foobar.com 44 | cd ~/comntr/captcha 45 | npm start & 46 | disown 47 | exit 48 | ``` 49 | 1. Check that the service is available at `https://foobar.com:2556/`. 50 | 1. Fork `comntr.github.io` to your own `username.github.io` or serve the web app from your own domain, e.g. `https://foobar:443/`. Make sure that references to `comntr.github.io` are updated to the new domain name. 51 | 1. Publish your own copy of the extension: 52 | ```bash 53 | cd comntr/webext 54 | npm run xpi-prod 55 | ``` 56 | -------------------------------------------------------------------------------- /src/webext.js: -------------------------------------------------------------------------------- 1 | const log = (...args) => log.i(...args); 2 | 3 | log.v = (...args) => { 4 | log.save('D', ...args); 5 | console.log('D', ...args); 6 | }; 7 | 8 | log.i = (...args) => { 9 | log.save('I', ...args); 10 | console.info('I', ...args); 11 | }; 12 | 13 | log.w = (...args) => { 14 | log.save('W', ...args); 15 | console.warn('W', ...args); 16 | }; 17 | 18 | log.e = (...args) => { 19 | log.save('E', ...args); 20 | console.error('E', ...args); 21 | }; 22 | 23 | log.logs = []; 24 | log.logs.maxlen = 4096; 25 | 26 | log.save = (tag, ...args) => { 27 | let time = new Date().toISOString(); 28 | let text = [time, tag, ...args].join(' '); 29 | log.logs.push(text); 30 | if (log.logs.length > log.logs.maxlen) 31 | log.logs.splice(0, 1); 32 | }; 33 | 34 | function webextcall(fn) { 35 | return new Promise((resolve, reject) => { 36 | fn((res, err) => { 37 | err ? reject(err) : resolve(res); 38 | }); 39 | }); 40 | } 41 | 42 | async function requestPermissions() { 43 | try { 44 | let granted = await webextcall(callback => { 45 | chrome.permissions.request({ 46 | permissions: [ 47 | 'tabs', 48 | ], 49 | }, callback); 50 | }); 51 | 52 | return granted; 53 | } catch (err) { 54 | log.e('permissions.request:', err); 55 | return false; 56 | } 57 | } 58 | 59 | async function getCurrentTab() { 60 | let tabs = await webextcall(callback => { 61 | chrome.tabs.query({ 62 | active: true, 63 | currentWindow: true, 64 | }, callback); 65 | }); 66 | 67 | return tabs[0]; 68 | } 69 | 70 | class StorageProp { 71 | constructor(name, defval) { 72 | this.name = name; 73 | this.defval = defval; 74 | } 75 | 76 | async get() { 77 | let props = {}; 78 | props[this.name] = null; 79 | let res = await webextcall(callback => 80 | chrome.storage.local.get(props, callback)); 81 | let value = res && res[this.name]; 82 | return value || this.defval; 83 | } 84 | 85 | async set(value) { 86 | let props = {}; 87 | props[this.name] = value; 88 | await webextcall(callback => 89 | chrome.storage.local.set(props, callback)); 90 | } 91 | } 92 | 93 | function sha1(str) { 94 | let bytes = new Uint8Array(str.length); 95 | 96 | for (let i = 0; i < str.length; i++) 97 | bytes[i] = str.charCodeAt(i) & 0xFF; 98 | 99 | return new Promise(resolve => { 100 | crypto.subtle.digest('SHA-1', bytes).then(buffer => { 101 | let hash = Array.from(new Uint8Array(buffer)).map(byte => { 102 | return ('0' + byte.toString(16)).slice(-2); 103 | }).join(''); 104 | 105 | resolve(hash); 106 | }); 107 | }); 108 | } 109 | -------------------------------------------------------------------------------- /docs/filters.md: -------------------------------------------------------------------------------- 1 | # How filters work 2 | 3 | Comntr filters are a lot like adblock filters. 4 | 5 | The web is infested with often inappropriate and sometimes outright malware ads. We solve this by installing a safety screen known as uBlock Origin: it shows us only a subset of the content that we select for ourselves. 6 | 7 | Say someone wants to install a comntr widget to their page to let others comment. However unmoderated comments section would quickly turn into a mess. This can be solved by displaying only some of the comments on the page. 8 | 9 | For example, the admin of `contoso.com` wants to show comments on the `contoso.com/foobar` page. So he adds the following `iframe` to `/foobar`: 10 | 11 | ```html 12 | 13 | ``` 14 | 15 | Comments are rendered in the `iframe` as usual. However there is an important difference: the `filter` param tells comntr to hide some of the comments. Everybody can post and read comments as usual. The admin, that has the proper ed25519 keypair, sees additional controls that can block users or comments. 16 | 17 | ``` 18 | tag:ABC -------------> SHA1 ----+ KDF(tag, publicKey) 19 | | 20 | v 21 | concat ----> SHA1 ----> filter:ab16...829 22 | ^ 23 | | 24 | publicKey:992...0cf -> SHA1 ----+ 25 | ``` 26 | 27 | When a common user sees the comments widget, comntr pulls both comments and the filters: 28 | - `GET /227...387` to get the comments 29 | - `GET /ab1...829` to get the filters 30 | 31 | Then it merges the two and displays only comments allowed by the filter. Can an advanced user just remove the `filter` param and see all the unmoderated comments? Absolutely. The goal of this system is to let the admin choose what to show on his page and only that. 32 | 33 | This is what a user sees: 34 | 35 | ![](/docs/user-view.png) 36 | 37 | When the admin sees the comments, comntr uses his public key to figure out that he is the owner of this filter and can do a few things: 38 | - Show additional controls that can block users or comments. 39 | - Add new filters to `ab1...829`. A new filter is just a regular comment for `ab1...829` where the comment text contains the blocked user id and the reason why. 40 | - Set the *rules* for `ab1...829` so others couldn't add random filters there. All the new filters would have to be signed by the admin's private key anyway, but that would require letting everyone know who the admin is by sharing his public key in the `iframe` src. The *rules* approach is more flexible also: it allows to specify any set of moderators who can modify the filters. But how does the data server know that the admin can set the rules for this `hash`? By verifying the provided public key, tag and signature and comparing `KDF(tag, publicKey)` with the `hash`. 41 | 42 | This is what the admin of this filter sees: 43 | 44 | ![](/docs/admin-view.png) 45 | 46 | 47 | -------------------------------------------------------------------------------- /docs/captcha.md: -------------------------------------------------------------------------------- 1 | # How captchas work 2 | 3 | Comntr captchas are like mail postmarks. 4 | 5 | In other words, the captcha service (CS) adds a ed25519 signature (a postmark) to a comment if it has a correct answer (A) to a question (Q) that corresponds to that comment (C). How does `CS` know what `(Q, A)` pair corresponds to `C`? By deriving it from its hash: `(Q, A) = F(H(C))`. A trivial derivation function can take the first few digits of `H(C)` and ask to add them up: 6 | 7 | ``` 8 | F(H(C)) = H(H(C) + salt) = 14fc..57f9 9 | Q = "1+4" 10 | A = "5" 11 | ``` 12 | The question is returned as an SVG picture. Once the question is answered, `CS` signs the comment and gives the signature, so other clients can verify it. This achieves a few things: 13 | 14 | - `CS` is almost stateless. It needs to remember `salt` and other variables, but they can change from time to time. 15 | - `CS` can instantly derive `(Q, A)` pairs from `H(C)` and instantly verify them. The most CPU intensive step here is producing the SVG picture. 16 | - The client can't produce `Q` on its own. 17 | - The function `F` that derives `(Q, A)` pairs can be arbitrary and can be changed daily. 18 | - Other clients can verify the postmarks themselves: they only need to get the public key from `CS`. The public key is given with an expiration date, so if gets stolen, `CS` could generate a new keypair to sign new comments, while still letting clients verify comments signed with the old keypair. 19 | 20 | The captchas extend the [/docs/filters.md](filters) idea: the admin of the filter can set rules that enable captchas. 21 | 22 | 1. The admin generates a filter id that matches his public key. 23 | 1. The admin uploads rules for the filter that enable captchas: 24 | ``` 25 | POST //rules 26 | { "owner": "...", "captcha": true } 27 | ``` 28 | 29 | Writing comments: 30 | 31 | 1. The client gets the rules with `GET //rules`. 32 | 1. The client writes a comment and sends `H(C)` to `CS`. 33 | 1. `CS` derives the `(Q, A)` pair and sends `Q` as an SVG picture to the client. 34 | 1. The client renders the picture and lets the user answer it. 35 | 1. The client sends `A` to `CS`. 36 | 1. `CS` produces the `(Q, A)` pair again and checks the answer. 37 | 1. `CS` signs `H(C)` with its private key and returns the signature to the client. 38 | 1. The client appends the signature to the comment. 39 | 40 | Reading comments: 41 | 42 | 1. The client gets the rules with `GET //rules`. 43 | 1. The client gets the public keys from `CS`. Multiple keys can be returned, each with its own expiration date: 44 | ``` 45 | -> GET /keys 46 | <- { "32..6f": "2019-08-14", "5f..77": "2020-11-03" } 47 | ``` 48 | 1. The client downloads the comments. 49 | 1. The client checks that every comment has a signature from `CS`. 50 | 51 | # The goal of these captchas 52 | 53 | The goal is to deter trolls who spam with meaningless comments. It's not a goal to stop sophisticated spammers that use text recognition software or botnets that ddos the service on purpose. The former can be better addressed by human moderators and the latter can't be stopped without specialized anti-ddos techniques. 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is this? 2 | 3 | It's a web extension for Firefox or Chrome that allows to add comments to any URL. 4 | 5 | ![](docs/screenshot.png) 6 | 7 | More precisely, it attaches comments to SHA1 of the URL, to avoid leaking PII: 8 | 9 | ``` 10 | +----------------------+ 11 | | The current tab URL: | 12 | | http://example.com/ | 13 | +----------------------+ 14 | | 15 | | 1. URL 16 | | 17 | v +-------------------------------------+ 18 | +--------------------+ |