cerca

lean forum software (pmc local branch)
Log | Files | Refs | README | LICENSE

commit 9cb07b43376f01d296fee261d1afe897156ed9b8
parent 6f8802509f09162b9725f1fa1816bbd7ae9cda1a
Author: Alexander Cobleigh <cblgh@cblgh.org>
Date:   Wed, 26 Oct 2022 14:46:16 +0200

Merge pull request #36 from cblgh/customize

Customize and add i18n support
Diffstat:
MREADME.md | 50+++++++++++++++++++++++++++++++++++++++++++++++---
Aabout.md | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mdatabase/database.go | 8++++----
Adefaults/defaults.go | 20++++++++++++++++++++
Adefaults/sample-about.md | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Adefaults/sample-config.toml | 10++++++++++
Adefaults/sample-logo.html | 16++++++++++++++++
Adefaults/sample-rules.md | 1+
Adefaults/sample-verification-instructions.md | 4++++
Mgo.mod | 4++--
Mgo.sum | 27+++++++++++++++++++++++++++
Ahtml/about-template.html | 7+++++++
Ahtml/assets/theme.css | 31+++++++++++++++++++++++++++++++
Mhtml/change-password-success.html | 8++++----
Mhtml/change-password.html | 13++++++-------
Mhtml/head.html | 53++++++++++++++++++++++++++++-------------------------
Mhtml/index.html | 2+-
Mhtml/login-component.html | 8++++----
Mhtml/login.html | 10+++++-----
Mhtml/new-thread.html | 10+++++-----
Mhtml/password-reset.html | 28+++++++++++-----------------
Mhtml/register-success.html | 13+++++--------
Mhtml/register.html | 41+++++++++++++++--------------------------
Mhtml/thread.html | 23+++++++++++------------
Ai18n/i18n.go | 367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mrun.go | 12+++++++++++-
Mserver/server.go | 368+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Atypes/types.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mutil/util.go | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
29 files changed, 1106 insertions(+), 264 deletions(-)

diff --git a/README.md b/README.md @@ -11,15 +11,60 @@ The reason it exists are many. To harbor longer form discussions, and for crawli threads and topics. For habitually visiting the site to see if anything new happened, as opposed to being obtrusively notified when in the middle of something else. For that sweet tinge of nostalgia that comes with the terrain, from having grown up in pace with the sprawling -phpBB forum communities of the mid naughties. +phpBB forum communities of the mid noughties. It was written for the purpose of powering the nascent [Merveilles community forums](https://forum.merveilles.town). +## Config +Cerca supports community customization. + +* Write a custom [about text](/defaults/sample-about.md) describing the community inhabiting the forum +* Define your own [registration rules](/defaults/sample-rules.md), [how to verify one's account](/defaults/sample-verification-instructions.md), and link to an existing code of conduct +* Set your own [custom logo](/defaults/sample-logo.html) (whether svg, png or emoji) +* Create your own theme by writing plain, frameworkless [css](/html/assets/theme.css) + +To enable these customizations, there's a config file. To choose a config file, run cerca with +the `--config` option; the default config file is set to `./cerca.toml`. + +``` +cerca --config ./configs/cerca.toml +``` + +The configuration format is [TOML](https://toml.io/en/) and the config is populated with the following +defaults: + +```TOML +[general] +name = "" # whatever you want to name your forum; primarily used as display in tab titles +conduct_url = "" # optional + recommended: if omitted, the CoC checkboxes in /register will be hidden +language = "English" # Swedish, English. contributions for more translations welcome! + +[documents] +logo = "content/logo.html" # can contain emoji, <img>, <svg> etc. see defaults/sample-logo.html in repo for instructions +about = "content/about.md" +rules = "content/rules.md" +verification_explanation = "content/verification-instructions.md" +``` + +Content documents that are not found will be prepoulated using Cerca's [sample content +files](/defaults). The easiest thing to do is to run Cerca once and let it populate content +files using the samples, and then edit the files in `content/*` after the fact, before running +Cerca again to see your changes. + +Either write your own configuration following the above format, or run cerca once to populate it and +then edit the created config. + ## Contributing If you want to join the fun, first have a gander at the [CONTRIBUTING.md](/CONTRIBUTING.md) document. It lays out the overall idea of the project, and outlines what kind of contributions will help improve the project. +### Translations + +Cerca supports use with different natural languages. To translate Cerca into your language, please +have a look at the existing [translations (i18n.go)](/i18n/i18n.go) and submit yours as a +[pull request](https://github.com/cblgh/cerca/compare). + ## Local development Install [golang](https://go.dev/). @@ -27,7 +72,6 @@ Install [golang](https://go.dev/). To launch a local instance of the forum, run those commands (linux): - `touch temp.txt` -- `mkdir data` - `go run run.go --authkey 0 --allowlist temp.txt --dev` -It should respond `Serving forum on :8277`. Just go on [http://localhost:8272](http://localhost:8272). +It should respond `Serving forum on :8277`. Just go on [http://localhost:8277](http://localhost:8277). diff --git a/about.md b/about.md @@ -0,0 +1,51 @@ +# About + +This forum is for and by the [Merveilles](https://wiki.xxiivv.com/site/merveilles.html) +community. + +The [forum software](https://github.com/cblgh/cerca) itself was created from scratch by +[cblgh](https://cblgh.org) at the start of 2022, after a long time of pining for a new wave of +forums hangs. + +If you are from Merveilles: [register](/register) an account. If you're a passerby, feel free to read the [public threads](/). + +## Code of conduct + +As with all Merveilles spaces, this forum abides by the compact set out in the [Merveilles Code +of Conduct](https://github.com/merveilles/Resources/blob/master/CONDUCT.md). + +## Forum syntax + +Posts in the forum are made using [Markdown syntax](https://en.wikipedia.org/wiki/Markdown#Examples). + +<b>\*\*Bold text\*\*</b> and <i>\*italics\*</i> + +<ul> + <li>* lists</li> + <li>* like </li> + <li>* this </li> +</ul> + +<blockquote>&gt; Blockquote</blockquote> + +<code>\`typewriter text\`</code> + +<!-- leave the <pre><code> blocks from reformatting! they render all their spacing :)--> +<pre><code>``` +blocks of +code like +this +``` +</code></pre> + +Create links like <code>\[this\]\(url\)</code>, and embed images like: <code>!\[description\]\(url\)</code>. Note how the image +syntax's exclamation mark precedes the regular link syntax. + +Each post in the thread can be referenced like <code>\[this post\]\(#12\)</code>, where 12 is the post number which can be +found at each post timestamp. + +<pre><code>this is one paragraph. +this belongs to the same paragraph. + +this is a new paragraph +</code></pre> diff --git a/database/database.go b/database/database.go @@ -22,13 +22,13 @@ type DB struct { func CheckExists(filepath string) bool { if _, err := os.Stat(filepath); errors.Is(err, os.ErrNotExist) { - return false - } - return true + return false + } + return true } func InitDB(filepath string) DB { - exists := CheckExists(filepath) + exists := CheckExists(filepath) if !exists { file, err := os.Create(filepath) if err != nil { diff --git a/defaults/defaults.go b/defaults/defaults.go @@ -0,0 +1,20 @@ +package defaults + +import ( + _ "embed" +) + +//go:embed sample-about.md +var DEFAULT_ABOUT string + +//go:embed sample-logo.html +var DEFAULT_LOGO string + +//go:embed sample-rules.md +var DEFAULT_RULES string + +//go:embed sample-verification-instructions.md +var DEFAULT_VERIFICATION string + +//go:embed sample-config.toml +var DEFAULT_CONFIG string diff --git a/defaults/sample-about.md b/defaults/sample-about.md @@ -0,0 +1,49 @@ +# About +This forum is for and by the [Merveilles](https://wiki.xxiivv.com/site/merveilles.html) +community. + +The [forum software](https://github.com/cblgh/cerca) itself was created from scratch by +[cblgh](https://cblgh.org) at the start of 2022, after a long time of pining for a new wave of +forums hangs. + +If you are from Merveilles: [register](/register) an account. If you're a passerby, feel free +to read the [public threads](/). + +## Code of conduct +As with all Merveilles spaces, this forum abides by the compact set out in the [Mervilles Code +of Conduct](https://github.com/merveilles/Resources/blob/master/CONDUCT.md). + +## Forum syntax +Posts in the forum are made using [Markdown syntax](https://en.wikipedia.org/wiki/Markdown#Examples). + +<b>\*\*Bold text\*\*</b> and <i>\*italics\*</i> + +<ul> + <li>* lists</li> + <li>* like </li> + <li>* this </li> +</ul> + +<blockquote>&gt; Blockquote</blockquote> + +<code>\`typewriter text\`</code> + +<!-- leave the <pre><code> blocks from reformatting! they render all their spacing :)--> +<pre><code>``` +blocks of +code like +this +``` +</code></pre> + +Create links like <code>\[this\]\(url\)</code>, and embed images like: <code>!\[description\]\(url\)</code>. Note how the image +syntax's exclamation mark precedes the regular link syntax. + +Each post in the thread can be referenced like <code>\[this post\]\(#12\)</code>, where 12 is the post number which can be +found at each post timestamp. + +<pre><code>this is one paragraph. +this belongs to the same paragraph. + +this is a new paragraph +</code></pre> diff --git a/defaults/sample-config.toml b/defaults/sample-config.toml @@ -0,0 +1,10 @@ +[general] +name = "" # whatever you want to name your forum; primarily used as display in tab titles +conduct_url = "" # optional + recommended: if omitted, the CoC checkboxes in /register will be hidden +language = "English" # Swedish, English. contributions for more translations welcome! + +[documents] +logo = "content/logo.html" # can contain emoji, <img>, <svg> etc. see defaults/sample-logo.html in repo for instructions +about = "content/about.md" +rules = "content/rules.md" +verification_explanation = "content/verification-instructions.md" diff --git a/defaults/sample-logo.html b/defaults/sample-logo.html @@ -0,0 +1,16 @@ +<!-- here's how to have an svg logo --> +<svg aria-hidden="true" style="margin-bottom: 0;" width="48" height="48" fill="black" stroke="none" viewBox="60 60 200 200" xmlns="http://www.w3.org/2000/svg"> +<g> +<path d="M185,65 A60,60 0 0,0 125,125 L185,125 Z M125,245 A60,60 0 0,0 185,185 L125,185 Z M95,125 A30,30 0 0,1 125,155 A30,30 0 0,1 95,185 A30,30 0 0,1 65,155 A30,30 0 0,1 95,125 Z M155,125 A30,30 0 0,1 185,155 A30,30 0 0,1 155,185 A30,30 0 0,1 125,155 A30,30 0 0,1 155,125 Z M215,125 A30,30 0 0,1 245,155 A30,30 0 0,1 215,185 A30,30 0 0,1 185,155 A30,30 0 0,1 215,125 "/> +<path d="M125,65 A60,60 0 0,1 185,125 L125,125 Z M185,245 A60,60 0 0,1 125,185 L185,185 Z M65,65 A60,60 0 0,1 125,125 L65,125 Z M65,245 A60,60 0 0,0 125,185 L65,185 Z M245,65 A60,60 0 0,0 185,125 L245,125 Z M245,245 A60,60 0 0,1 185,185 L245,185 Z"/> +</g> +</svg> + +<!-- here's how to have a png logo +it's important with the path starting with /assets/, and that the loaded asset is inside dir <cercaroot>/html/assets +<img src="/assets/merveilles.png"> +--> + +<!-- here's how to have an emoji logo +<h1>🍵</h1> +--> diff --git a/defaults/sample-rules.md b/defaults/sample-rules.md @@ -0,0 +1 @@ +This forum is for the [Merveilles](https://wiki.xxiivv.com/site/merveilles.html) community. To register, you need to either belong to the [Merveilles Webring](https://webring.xxiivv.com) or the [Merveilles Fediverse instance](https://merveilles.town). diff --git a/defaults/sample-verification-instructions.md b/defaults/sample-verification-instructions.md @@ -0,0 +1,4 @@ +You can use either your mastodon profile or your webring site to verify your registration. + +* **Mastodon**: temporarily add a new metadata item to [your profile](https://merveilles.town/settings/profile) containing the verification code displayed above. Pass your profile as the verification link. +* **Webring site**: Upload a plaintext file somewhere on your webring domain (incl. subdomain) containing the verification code from above. Pass the link to the uploaded file as the verification link (make sure it is viewable in a browser). diff --git a/go.mod b/go.mod @@ -6,11 +6,11 @@ require ( github.com/carlmjohnson/requests v0.22.1 // indirect github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df // indirect github.com/gorilla/sessions v1.2.1 // indirect + github.com/komkom/toml v0.1.2 // indirect github.com/mattn/go-sqlite3 v1.14.9 // indirect github.com/microcosm-cc/bluemonday v1.0.17 // indirect github.com/stretchr/testify v1.7.0 // indirect github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b // indirect golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect - golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d // indirect - golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect + golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e // indirect ) diff --git a/go.sum b/go.sum @@ -8,6 +8,7 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df h1:M7mdNDTRraBcrHZg2aOYiFP9yTDajb6fquRZRpXnbVA= github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= @@ -15,30 +16,47 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/komkom/toml v0.1.2 h1:SexwnY3JOR0kU9F/xxw/129BPCvuKi6/E89PZ4kSSBo= +github.com/komkom/toml v0.1.2/go.mod h1:cgnL/ntRyMHaZuDy9wREJHWY1Cb2HEINK7U0YhpcTa8= github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b h1:Yu/2y+2iAAcTRfdlMZ3dEdb1aYWXesDDaQjb7xLgy7Y= github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b/go.mod h1:RQlLg9p2W+/d3q6xRWilTA2R4ltKiwEmzoI1urnKm9U= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e h1:SkwG94eNiiYJhbeDE018Grw09HIN/KB9NlRmZsrzfWs= +golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d h1:62NvYBuaanGXR2ZOfwDFkhhl6X1DUgf8qg3GuQvxZsE= golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -46,10 +64,19 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/html/about-template.html b/html/about-template.html @@ -0,0 +1,7 @@ +{{ template "head" . }} +<main> + <article> + {{ .Data }} + </article> +</main> +{{ template "footer" . }} diff --git a/html/assets/theme.css b/html/assets/theme.css @@ -0,0 +1,31 @@ +/* below are the style rules that define the visual theme of the forum + * change these to change the forum's colours */ + +/* normal theme */ +blockquote { border-color: black } +a:not([class]) { color: black; } +a:visited { color: #666; } +tr:hover td { background:#e2cca2; } +body { background: wheat; color: black; } + +header > a { background-image: url('/assets/merveilles.png'); } +header details a:visited { color: black; } + +/* halloween theme */ +header svg { fill: #ff8000; } +#logo { + width: 48px; + height: 48px; + display: block; +} +p { color: #f2f2f2; } +blockquote { border-color: wheat; } +textarea { background: black; color: wheat; } +h1 a:visited, a:not([class]) { color: wheat; } +a:visited { color: gray; } +body { background: #111; color: #ff8000; } + +header details a:visited { color: #ff8000; } + +/* author colors */ +span > b { color: #ff8000; } diff --git a/html/change-password-success.html b/html/change-password-success.html @@ -1,12 +1,12 @@ {{ template "head" . }} -<h1>Change password</h1> -<p>Your password was successfully changed! Please store it somewhere safe.</p> +<h1>{{ "ChangePassword" | translate | capitalize }}</h1> +<p>{{ "PasswordResetSuccessMessage" | translate }}</p> {{ if ne .Data.Keypair "" }} -<p> Here is your newly regenerated keypair, please save it somewhere in case you forget your password and need to reset it.</p> +<p> {{ "RegisterKeypairWarning" | translate }}.</p> <pre class="selectable"> {{ .Data.Keypair }} </pre> {{ end }} -<p>Go back to the <a href="/">index</a>.</p> +<p> {{ "RegisterLinkMessage" | translate }} <a href="/">{{ "Index" | translate }}</a>.</p> {{ template "footer" . }} diff --git a/html/change-password.html b/html/change-password.html @@ -1,20 +1,19 @@ {{ template "head" . }} -<h1>Change password</h1> -<p>Use this page to change your password. If needed, you can also regenerate your password reset keypair—used to reset a -forgotten password without admin help.</p> +<h1> {{ "ChangePassword" | translate | capitalize }}</h1> +<p>{{ "ChangePasswordDescription" | translate }}</p> <form method="post" action="{{.Data.Action}}"> <div> - <label type="text" for="password-old">Current password:</label> + <label type="text" for="password-old">{{ "Current" | translate | capitalize }} {{ "Password" | translate }}:</label> <input type="password" minlength="9" required id="password-old" name="password-old" aria-describedby="password-help"> </div> <div> - <label type="text" for="password-new">New password:</label> + <label type="text" for="password-new">{{ "New" | translate | capitalize }} {{ "Password" | translate }}:</label> <input type="password" style="margin-bottom: 0;" minlength="9" required id="password-new" name="password-new" aria-describedby="password-help"> - <div><small id="password-help">Must be at least 9 characters long.</small></div> + <div><small id="password-help">{{ "PasswordMin" | translate }}.</small></div> </div> <div> <input type="checkbox" value="true" name ="reset-keypair" id="reset-keypair"> - <label for="reset-keypair" style="display: inline-block;">I also want to generate a new keypair</label> + <label for="reset-keypair" style="display: inline-block;">{{ "GenerateNewKeypair" | translate }}</label> </div> <div> <input type="submit" value="Submit"> diff --git a/html/head.html b/html/head.html @@ -5,7 +5,7 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Forum — {{ .Title }}</title> + <title>{{ .ForumName }} — {{ .Title }}</title> <style> /* reset */ @@ -27,6 +27,9 @@ label { display: block; } + header svg { + fill: #ff8000; + } article h1, article h2, article h3 { /* normalize post titles */ font-size: 1rem; @@ -46,19 +49,19 @@ li { margin-bottom: 0rem; } ul { padding-left: 1rem; } h1, h2 { margin-bottom: 1rem; } - p { margin-bottom: 1rem; } - blockquote { padding-left: 1rem; border-left: 3px solid black } + p { margin-bottom: 1rem; color: #f2f2f2; } + blockquote { padding-left: 1rem; border-left: 3px solid wheat; } div { margin-bottom: 2rem; } - textarea { min-height: 10rem; } + textarea { min-height: 10rem; background: black; color: wheat; } article > section { margin-bottom: 0.5rem; } article { margin-bottom: 2rem; } form div, label { margin-bottom: 0; } h1 a:visited, a:not([class]) { - color: black; + color: wheat; } a:visited { - color: #666; /* for lack of better imagination */ + color: gray; /* #666; /* for lack of better imagination */ } .selectable { -webkit-touch-callout: all; @@ -83,7 +86,7 @@ width: 100%; } - body { padding: 2rem; background: wheat; } + body { padding: 2rem; background: #111; color: #ff8000; /*wheat;*/ } * { margin-bottom: 1rem; } .visually-hidden { @@ -117,7 +120,6 @@ } header > a { - background-image: url('/assets/merveilles.png'); background-size: 100%; background-repeat: no-repeat; display: inline-block; @@ -136,7 +138,7 @@ margin-bottom: unset; } header details a:visited { - color: black; + color: #ff8000; } header details ul { position: absolute; @@ -145,6 +147,11 @@ display: block; } + /* post author name */ + span > b { + color: #ff8000; + } + @supports (display: flex) { header > a { background-image: none; @@ -158,7 +165,7 @@ } </style> - <!-- <link href="/assets/style.css" rel="stylesheet"> --> + <link href="/assets/theme.css" rel="stylesheet"> <link rel="icon" href="/assets/favicon.png"> <!-- <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml"> --> @@ -183,36 +190,32 @@ </head> <body> <header> - <a style="margin-bottom: 0; height: 48px;" href="/" aria-label="Home"> - <svg aria-hidden="true" style="margin-bottom: 0;" width="48" height="48" fill="black" stroke="none" viewBox="60 60 200 200" xmlns="http://www.w3.org/2000/svg"> - <g> - <path d="M185,65 A60,60 0 0,0 125,125 L185,125 Z M125,245 A60,60 0 0,0 185,185 L125,185 Z M95,125 A30,30 0 0,1 125,155 A30,30 0 0,1 95,185 A30,30 0 0,1 65,155 A30,30 0 0,1 95,125 Z M155,125 A30,30 0 0,1 185,155 A30,30 0 0,1 155,185 A30,30 0 0,1 125,155 A30,30 0 0,1 155,125 Z M215,125 A30,30 0 0,1 245,155 A30,30 0 0,1 215,185 A30,30 0 0,1 185,155 A30,30 0 0,1 215,125 "/> - <path d="M125,65 A60,60 0 0,1 185,125 L125,125 Z M185,245 A60,60 0 0,1 125,185 L185,185 Z M65,65 A60,60 0 0,1 125,125 L65,125 Z M65,245 A60,60 0 0,0 125,185 L65,185 Z M245,65 A60,60 0 0,0 185,125 L245,125 Z M245,245 A60,60 0 0,1 185,185 L245,185 Z"/> - </g> - </svg> + <a style="margin-bottom: 0; height: 48px;" href="/" aria-label='{{ "AriaHome" | translate }}'> + {{ dumpLogo }} </a> <nav> <ul type="menu"> - {{ if eq .Title "threads" }} + {{ $threads := "Threads" | translate }} + {{ if eq .Title $threads }} <li> <details> - <summary>sort</summary> + <summary>{{ "Sort" | translate }}</summary> <ul> - <li> <a href="/?sort=posts">recent posts</a></li> - <li> <a href="/">most recent thread</a></li> + <li> <a href="/?sort=posts">{{ "SortRecentPosts" | translate }}</a></li> + <li> <a href="/">{{ "SortRecentThreads" | translate }}</a></li> </ul> </details> </li> {{ end }} {{ if .QuickNav }} - <li><a href="#bottom">bottom</a></li> + <li><a href="#bottom">{{ "Bottom" | translate }}</a></li> {{end}} - <li><a href="/about">about</a></li> + <li><a href="/about">{{ "About" | translate }}</a></li> {{ if .LoggedIn }} - <li><a href="/logout">logout</a></li> + <li><a href="/logout">{{"Logout" | translate }}</a></li> {{ else }} - <li><a href="/login">login</a></li> + <li><a href="/login">{{ "Login" | translate }}</a></li> {{ end }} </ul> </nav> diff --git a/html/index.html b/html/index.html @@ -6,7 +6,7 @@ </main> {{ if .LoggedIn }} <aside> - <p> <a href="/thread/new">Start a new thread</a></p> + <p> <a href="/thread/new">{{ "ThreadStartNew" | translate }}</a></p> </aside> {{ end }} {{ template "footer" . }} diff --git a/html/login-component.html b/html/login-component.html @@ -2,15 +2,15 @@ <form method="post" action="/login"> <div style="display: grid;"> <div> - <label for="username">Username:</label> + <label for="username">{{ "Username" | translate | capitalize }}:</label> <input type="text" name="username" id="username"> </div> <div> - <label for="password">Password:</label> + <label for="password">{{ "Password" | translate | capitalize }}:</label> <input type="password" name="password" id="password" style="margin-bottom:0;" aria-describedby="password-help"> - <div><small id="password-help">Must be at least 9 characters long.</small></div> + <div><small id="password-help">{{ "PasswordMin" | translate }}</small></div> </div> - <input type="submit" value="Enter" style="margin-top:1rem;"> + <input type="submit" value='{{ "Enter" | translate | capitalize }}' style="margin-top:1rem;"> </div> </form> {{ end }} diff --git a/html/login.html b/html/login.html @@ -1,15 +1,15 @@ {{ template "head" . }} <main> - <h1>Login</h1> - <p>This forum is for the <a href="https://wiki.xxiivv.com/site/merveilles.html">Merveilles</a> community. Don't have an account yet? <a href="/register">Register</a> one. </p> + <h1>{{ "Login" | translate | capitalize }}</h1> + <p>{{ "ForumDescription" | translateWithData | tohtml }} {{ "LoginNoAccount" | translate | tohtml }}</p> <div style="max-width: 20rem"> {{ template "login-component" . }} - <p><a href="/reset">Forgot your password?</a></p> + <p><a href="/reset">{{ "PasswordForgot" | translate }}</a></p> </div> {{ if .Data.FailedAttempt }} - <p><b>Failed login attempt:</b> incorrect password, wrong username, or a non-existent user.</p> + <p> {{ "LoginFailure" | translate | tohtml }} </p> {{ else if .LoggedIn }} - <p> You are already logged in. Would you like to <a href="/logout">log out</a>?</p> + <p>{{ "LoginAlreadyLoggedIn" | translate | tohtml }}</p> {{ end }} </p> </main> diff --git a/html/new-thread.html b/html/new-thread.html @@ -1,13 +1,13 @@ {{ template "head" . }} <main> - <h1>Create thread</h1> + <h1>{{ "ThreadCreate" | translate }}</h1> <form method="POST"> <div class="post-container" > - <label for="title">Title:</label> + <label for="title">{{ "Title" | translate }}:</label> <input required name="title" type="text" id="Title"> - <label for="content">Content:</label> - <textarea required name="content" id="content" placeholder="Tabula rasa"></textarea> - <button type="submit">Create</button> + <label for="content">{{ "Content" | translate }}:</label> + <textarea required name="content" id="content" placeholder='{{ "TextareaPlaceholder" | translate }}'></textarea> + <button type="submit">{{ "Create" | translate }}</button> </div> </form> </main> diff --git a/html/password-reset.html b/html/password-reset.html @@ -1,12 +1,12 @@ {{ template "head" . }} -<p>On this page we'll go through a few steps to securely reset your password—without resorting to any emails!</p> -<p>First up: what was your username?</p> +<p>{{ "PasswordResetDescription" | translate }}</p> +<p>{{ "PasswordResetUsernameQuestion" | translate }}</p> {{ if eq .Data.Action "/reset/generate" }} <form method="post" action="{{.Data.Action}}"> - <label type="text" for="username">Username:</label> + <label type="text" for="username">{{ "Username" | translate | capitalize }}:</label> <input required id="username" name="username"> <div> - <input type="submit" value="Generate payload"> + <input type="submit" value='{{ "GeneratePayload" | translate | capitalize }}'> </div> </form> {{ end }} @@ -14,31 +14,25 @@ {{ if eq .Data.Action "/reset/submit" }} <input disabled value="{{ .Data.Username }}"> -<p>Now, first copy the snippet (aka <i>proof payload</i>) below:</p> +<p>{{ "PasswordResetCopyPayload" | translate | tohtml }}:</p> <pre style="user-select: all;"> <code>{{ .Data.Payload }}</code> </pre> -<p>Follow the <b>tool instructions</b> to finalize the password reset.</p> +<p> {{ "PasswordResetFollowToolInstructions" | translate | tohtml }}</p> <details> - <summary>Tool instructions</summary> - <ul> - <li><a href="https://github.com/cblgh/cerca/releases/tag/pwtool-v1">Download the tool</a></li> - <li>Run as:<br><code>pwtool --payload &lt;proof payload from above&gt; --keypair &lt;path to file with yr keypair from registration&gt;</code> - </li> - <li>Copy the generated proof and paste below</li> - <li>(Remember to save your password :)</li> - </ul> + <summary>{{ "ToolInstructions" | translate | capitalize }}</summary> + {{ "PasswordResetToolInstructions" | translate | tohtml }} </details> <form method="post" action="{{.Data.Action}}"> <input type="hidden" required id="username" name="username" value="{{ .Data.Username }}"> <input type="hidden" required id="payload" name="payload" value="{{ .Data.Payload }}"> - <label for="proof">Proof</label> + <label for="proof">{{ "Proof" | translate | capitalize }}</label> <input type="text" required id="proof" name="proof"> - <label for="password">New password</label> + <label for="password">{{ "NewPassword" | translate | capitalize }}</label> <input type="password" minlength="9" required id="password" name="password" aria-describedby="password-help"> <div> - <input type="submit" value="Change password"> + <input type="submit" value='{{ "ChangePassword" | translate | capitalize }}'> </div> </form> {{ end }} diff --git a/html/register-success.html b/html/register-success.html @@ -1,13 +1,10 @@ {{ template "head" . }} <main> - <h1>Register</h1> - <p>You now have an account! Welcome. Visit the <a href="/">index</a> to read and reply to threads, or start a new one.</p> - <p>There's just one more thing: <b>save the key displayed below</b>. It is a <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">keypair</a> - describing your forum identity, with a private part that only you know; the forum only stores the public portion.</p> - - <p>With this keypair you will be able to reset your account if you ever lose your password—and without having to share your email (or require - email infrastructure on the forum's part).</p> - <p><i><b>This keypair will only be displayed once</i></b></p> + <h1>{{ "Register" | translate | capitalize }}</h1> + <p>{{ "RegisterHTMLMessage" | translate | tohtml }}</p> + <p>{{ "RegisterKeypairExplanationStart" | translate | tohtml }}</p> + <p>{{ "RegisterKeypairExplanationEnd" | translate | tohtml }}</p> + <p><i><b>{{ "RegisterKeypairWarning" | translate }}</i></b></p> <code> <pre class="selectable"> {{ .Data.Keypair }} diff --git a/html/register.html b/html/register.html @@ -1,49 +1,38 @@ {{ template "head" . }} <main> - <h1>Register</h1> - - <p>This forum is for the <a href="https://wiki.xxiivv.com/site/merveilles.html">Merveilles</a> community. To register, you need to either belong to the <a href="https://webring.xxiivv.com">Merveilles Webring</a> or the - <a href="https://merveilles.town">Merveilles Fediverse instance</a>. - </p> - - <p>Your verification code is <b>{{ .Data.VerificationCode }}</b></p> + <h1> {{ "Register" | translate | capitalize }}</h1> + {{ .Data.Rules }} <!-- registrationregistration rules will be inserted here from the rules document being read from the config --> + <p>{{ "RegisterVerificationCode" | translate }} <b>{{ .Data.VerificationCode }}</b></p> <details> - <summary>Verification instructions</summary> - <p>You can use either your mastodon profile or your webring site to verify your registration.</p> - <ul> - <li><b>Mastodon:</b> temporarily add a new metadata item to <a - href="https://merveilles.town/settings/profile">your profile</a> containing the verification code - displayed above. Pass your profile as the verification link.</li> - <li><b>Webring site:</b> Upload a plaintext file somewhere on your webring domain (incl. subdomain) containing - the verification code from above. Pass the link to the uploaded file as the verification link (make sure - it is viewable in a browser).</li> - </ul> + <summary> {{ "RegisterVerificationInstructionsTitle" | translate }}</summary> + <!-- add your communities verification instructions by editing the verification-instructions document, see the config for where to find it!--> + {{ .Data.VerificationInstructions }} </details> <form method="post"> - <label for="username">Username:</label> + <label for="username">{{ "Username" | translate | capitalize }}:</label> <input type="text" required id="username" name="username"> - <label for="password">Password:</label> + <label for="password">{{ "Password" | translate | capitalize }}:</label> <input type="password" minlength="9" required id="password" name="password" aria-describedby="password-help" style="margin-bottom:0;"> - <div style="margin-bottom:1rem;"><small id="password-help">Must be at least 9 characters long.</small></div> - <label for="verificationlink">Verification link:</label> + <div style="margin-bottom:1rem;"><small id="password-help">{{ "PasswordMin" | translate }}.</small></div> + <label for="verificationlink">{{ "RegisterVerificationLink" | translate }}: </label> <input type="text" required id="verification link" name="verificationlink"> <input type="hidden" name="verificationcode" value="{{.Data.VerificationCode}}"> + {{ if ne .Data.ConductLink "" }} <div> <div> <input type="checkbox" required id="coc"> - <label for="coc" style="display: inline-block;">I have refreshed my memory of the <a target="_blank" href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">Merveilles Code of Conduct</a></label> + <label for="coc" style="display: inline-block;">{{ "RegisterConductCodeBoxOne" | translateWithData | tohtml }}</label> </div> <div> <input type="checkbox" required id="coc2" > - <label style="display: inline;" for="coc2">Yes, I have actually <a target="_blank" href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">read it</a></label> + <label style="display: inline;" for="coc2">{{ "RegisterConductCodeBoxTwo" | translateWithData | tohtml }}</label> </div> </div> - <input type="submit" value="Register"> + {{ end }} + <input type="submit" value='{{ "Register" | translate | capitalize }}'> </form> - - {{ if .Data.ErrorMessage }} <div> <p><b> {{ .Data.ErrorMessage }} </b></p> diff --git a/html/thread.html b/html/thread.html @@ -5,23 +5,22 @@ {{ $threadURL := .Data.ThreadURL }} {{ range $index, $post := .Data.Posts }} <article id="{{ $post.ID }}"> - <section aria-label="Post meta"> + <section aria-label='{{ "AriaPostMeta" | translate }}'> {{ if eq $post.AuthorID $userID }} - <span style="float: right;" aria-label="Delete this post"> + <span style="float: right;" aria-label='{{ "AriaDeletePost" | translate }}'> <form style="display: inline-block;" method="POST" action="/post/delete/{{ $post.ID }}" - onsubmit="return confirm('Delete post for all posterity?');" - > - <button style="background-color: transparent; border: 0; padding: 0;" type="submit">delete</button> + onsubmit="return confirm('{{"PromptDeleteQuestion" | translate }}');"> + <button style="background-color: transparent; border: 0; padding: 0;" type="submit"> {{ "Delete" | translate }}</button> <input type="hidden" name="thread" value="{{ $threadURL }}"> </form> </span> {{ end }} - <span class="visually-hidden">Author:</span> + <span class="visually-hidden">{{ "Author" | translate }}:</span> <span><b>{{ $post.Author }}</b> - <span class="visually-hidden"> responded:</span> + <span class="visually-hidden"> {{ "Responded" | translate }}:</span> </span> <a href="#{{ $post.ID }}"> - <span style="margin-left: 0.5rem; font-style: italic;"> + <span style="margin-left: 0.5rem;"> <time datetime="{{ $post.Publish | formatDate }}">{{ $post.Publish | formatDateRelative }}</time> </span> </a> @@ -30,12 +29,12 @@ </article> {{ end }} {{ if .LoggedIn }} - <section aria-label="Respond into this thread"> + <section aria-label='{{ "AriaRespondIntoThread" | translate }}'> <form method="POST"> <div id="bottom" class="post-container" > - <label class="visually-hidden" for="content">Your answer:</label> - <textarea required name="content" id="content" placeholder="Tabula rasa"></textarea> - <button type="submit">Post</button> + <label class="visually-hidden" for="content">{{ "YourAnswer" | translate }}:</label> + <textarea required name="content" id="content" placeholder='{{ "TextareaPlaceholder" | translate }}'></textarea> + <button type="submit">{{ "Post" | translate | capitalize }}</button> </div> </form> </section> diff --git a/i18n/i18n.go b/i18n/i18n.go @@ -0,0 +1,367 @@ +package i18n + +import ( + "cerca/util" + "fmt" + "html/template" + "log" + "strings" +) + +const toolURL = "https://github.com/cblgh/cerca/releases/tag/pwtool-v1" + +var English = map[string]string{ + "About": "about", + "Login": "login", + "Logout": "logout", + "Sort": "sort", + "Enter": "enter", + "Register": "register", + + "LoggedIn": "logged in", + "NotLoggedIn": "Not logged in", + "LogIn": "log in", + "GoBack": "Go back", + + "SortRecentPosts": "recent posts", + "SortRecentThreads": "most recent threads", + + "LoginNoAccount": "Don't have an account yet? <a href='/register'>Register</a> one.", + "LoginFailure": "<b>Failed login attempt:</b> incorrect password, wrong username, or a non-existent user.", + "LoginAlreadyLoggedIn": `You are already logged in. Would you like to <a href="/logout">log out</a>?`, + + "Username": "username", + "Current": "current", + "New": "new", + "GenerateNewKeypair": "I also want to generate a new keypair", + "ChangePasswordDescription": "Use this page to change your password. If needed, you can also regenerate your password reset keypair—used to reset a forgotten password without admin help.", + "Password": "password", + "PasswordMin": "Must be at least 9 characters long", + "PasswordForgot": "Forgot your password?", + + "Threads": "threads", + "ThreadNew": "new thread", + "ThreadThe": "the thread", + "Index": "index", + + "ThreadCreate": "Create thread", + "Title": "Title", + "Content": "Content", + "Create": "Create", + "TextareaPlaceholder": "Tabula rasa", + + "PasswordReset": "reset password", + "PasswordResetMessage": "You are logged in, log out to reset password using proof", + "PasswordResetSuccess": "Reset password—success!", + "PasswordResetSuccessMessage": "You reset your password!", + "PasswordResetSuccessLinkMessage": "Give it a try and", + + "RegisterMessage": "You already have an account (you are logged in with it).", + "RegisterLinkMessage": "Visit the", + "RegisterSuccess": "registered successfully", + + "ErrUnaccepted": "Unaccepted request", + "ErrThread404": "Thread not found", + "ErrThread404Message": "The thread does not exist (anymore?)", + "ErrGeneric404": "Page not found", + "ErrGeneric404Message": "The visited page does not exist (anymore?). Error code %d.", + + "NewThreadMessage": "Only members of this forum may create new threads", + "NewThreadLinkMessage": "If you are a member,", + "NewThreadCreateError": "Error creating thread", + "NewThreadCreateErrorMessage": "There was a database error when creating the thread, apologies.", + + "AriaPostMeta": "Post meta", + "AriaDeletePost": "Delete this post", + "AriaRespondIntoThread": "Respond into this thread", + "PromptDeleteQuestion": "Delete post for all posterity?", + "Delete": "delete", + "Post": "post", + "Author": "Author", + "Responded": "responded", + "YourAnswer": "Your answer", + + "AriaHome": "Home", + "ThreadStartNew": "Start a new thread", + + "RegisterHTMLMessage": `You now have an account! Welcome. Visit the <a href="/">index</a> to read and reply to threads, or start a new one.`, + "RegisterKeypairExplanationStart": `There's just one more thing: <b>save the key displayed below</b>. It is a <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">keypair</a> describing your forum identity, with a private part that only you know; the forum only stores the public portion.`, + "RegisterViewKeypairExplanationEnd": `With this keypair you will be able to reset your account if you ever lose your password—and without having to share your email (or require email infrastructure on the forum's part).`, + "RegisterKeypairWarning": "This keypair will only be displayed once", + + "RegisterVerificationCode": "Your verification code is", + "RegisterVerificationInstructionsTitle": "Verification instructions", + "RegisterVerificationLink": "Verification link", + "RegisterConductCodeBoxOne": `I have refreshed my memory of the <a target="_blank" href="{{ .Data.Link }}">{{ .Data.Name }} Code of Conduct</a>`, + "RegisterConductCodeBoxTwo": `Yes, I have actually <a target="_blank" href="{{ .Data.Link }}">read it</a>`, + + "PasswordResetDescription": "On this page we'll go through a few steps to securely reset your password—without resorting to any emails!", + "PasswordResetUsernameQuestion": "First up: what was your username?", + "PasswordResetCopyPayload": `Now, first copy the snippet (aka <i>proof payload</i>) below`, + "PasswordResetFollowToolInstructions": `Follow the <b>tool instructions</b> to finalize the password reset.`, + "ToolInstructions": `tool instructions`, + "PasswordResetToolInstructions": fmt.Sprintf(` + <ul> + <li><a href="%s">Download the tool</a></li> + <li>Run as:<br><code>pwtool --payload &lt;proof payload from above&gt; --keypair &lt;path to file with yr keypair from registration&gt;</code> + </li> + <li>Copy the generated proof and paste below</li> + <li>(Remember to save your password :)</li> + </ul> + `, toolURL), + "GeneratePayload": "generate payload", + "Proof": "proof", + "NewPassword": "new password", + "ChangePassword": "change password", +} + +var Swedish = map[string]string{ + "About": "om", + "Login": "logga in", + "Logout": "logga ut", + "Sort": "sortera", + "Enter": "skicka", + "Register": "registrera", + + "LoggedIn": "inloggad", + "NotLoggedIn": "Ej inloggad", + "LogIn": "logga in", + "GoBack": "Go back", + + "SortRecentPosts": "nyast poster", + "SortRecentThreads": "nyast trådar", + + "LoginNoAccount": "Saknar du konto? <a href='/register'>Skapa</a> ett.", + "LoginFailure": "<b>Misslyckat inloggningsförsök:</b> inkorrekt lösenord, fel användernamn, eller obefintlig användare.", + "LoginAlreadyLoggedIn": `Du är redan inloggad. Vill du <a href="/logout">logga ut</a>?`, + + "Username": "användarnamn", + "Current": "nuvarande", + "New": "nytt", + "GenerateNewKeypair": "Jag vill också generera ett nytt nyckelpar", + "ChangePasswordDescription": "På den här sidan kan du ändra ditt lösenord. Vid behov kan du också regenerera ditt nyckelpar—används för att nollställa ditt lösenord utan att be admin om hjälp.", + "Password": "lösenord", + "PasswordMin": "Måste vara minst 9 karaktärer långt", + "PasswordForgot": "Glömt lösenordet?", + + "Threads": "trådar", + "ThreadNew": "ny tråd", + "ThreadThe": "tråden", + "Index": "index", + + "ThreadCreate": "Skapa en tråd", + "Title": "Titel", + "Content": "Innehåll", + "Create": "Skapa", + "TextareaPlaceholder": "Tabula rasa", + + "PasswordReset": "nollställ lösenord", + "PasswordResetMessage": "Du är inloggad, logga ut för att nollställga lösenordet med skapat lösenordsbevis", + "PasswordResetSuccess": "Nollställning av lösenord—lyckades!", + "PasswordResetSuccessMessage": "Du har nollställt ditt lösenord!", + "PasswordResetSuccessLinkMessage": "Ge det ett försök och", + + "RegisterMessage": "Du har redan ett konto (du är inloggad med det).", + "RegisterLinkMessage": "Besök", + "RegisterSuccess": "konto skapat", + + "ErrUnaccepted": "Ej accepterat request", + "ErrThread404": "Tråd ej funnen", + "ErrThread404Message": "Denna tråden finns ej (längre?)", + "ErrGeneric404": "Sida ej funnen", + "ErrGeneric404Message": "Den besökta sidan finns ej (längre?). Felkod %d.", + + "NewThreadMessage": "Enbart medlemmarna av detta forum får skapa nya trådar", + "NewThreadLinkMessage": "Om du är en medlem,", + "NewThreadCreateError": "Fel uppstod vid trådskapning", + "NewThreadCreateErrorMessage": "Det uppstod ett databasfel under trådskapningen, ursäkta.", + + "AriaPostMeta": "Post meta", + "AriaDeletePost": "Delete this post", + "AriaRespondIntoThread": "Respond into this thread", + "PromptDeleteQuestion": "Radera post för alltid?", + "Delete": "radera", + "Post": "post", + "Author": "Författare", + "Responded": "svarade", + "YourAnswer": "Ditt svar", + + "AriaHome": "Hem", + "ThreadStartNew": "Starta ny tråd", + + "RegisterHTMLMessage": `Du har nu ett konto! Välkommen. Besök <a href="/">trådindexet</a> för att läsa och svara på trådar, eller för att starta en ny.`, + "RegisterKeypairExplanationStart": `En grej till: <b>spara nyckeln du ser nedan</b>. Det är ett <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">nyckelpar</a> som tillhandahåller din forumidentitet, och inkluderar en hemlig del som bara du vet om och endast visas nu; forumdatabasen kommer enbart ihåg den publika delen.`, + "RegisterViewKeypairExplanationEnd": `Med detta nyckelpar kan du återställa ditt lösenord om du skulle tappa bort det—och detta utan att behöva samla in din email (eller kräva emailinfrastruktur på forumets sida).`, + "RegisterKeypairWarning": "Detta nyckelpar visas enbart denna gång", + + "RegisterVerificationCode": "Din verifikationskod är", + "RegisterVerificationInstructionsTitle": "Verification instructions", + "RegisterVerificationLink": "Verificationsnyckel", + "RegisterConductCodeBoxOne": `I have refreshed my memory of the <a target="_blank" href="{{ .Data.Link }}">{{ .Data.Name }} Code of Conduct</a>`, + "RegisterConductCodeBoxTwo": `Yes, I have actually <a target="_blank" href="{{ .Data.Link }}">read it</a>`, + + "PasswordResetDescription": "På denna sida går vi igenom ett par steg för att säkert nollställa ditt lösenord—utan att behöva ta till mejl!", + "PasswordResetUsernameQuestion": "För de första: hur löd användarnamnet?", + "PasswordResetCopyPayload": `Kopiera nu textsnutten nedan (aka <i>beviset</i>)`, + "PasswordResetFollowToolInstructions": `Följ <b>verktygsinstruktionerna</b> för att finalisera nollställningen.`, + "ToolInstructions": `verktygsinstruktionerna`, + "PasswordResetToolInstructions": fmt.Sprintf(` + <ul> + <li><a href="%s">Ladda ned verktyget</a></li> + <li>Kör det så hör:<br><code>pwtool --payload &lt;payload från ovan&gt; --keypair &lt;filvägen innehållandes ditt nyckelpar från när du registrerade dig&gt;</code> + </li> + <li>Kopiera det genererade beviset och klistra in nedan</li> + <li>(Kom ihåg att spara ditt lösenord:)</li> + </ul> + `, toolURL), + "GeneratePayload": "skapa payload", + "Proof": "bevis", + "NewPassword": "nytt lösenord", + "ChangePassword": "ändra lösenord", +} + +var EspanolMexicano = map[string]string{ + "About": "acerca de", + "Login": "loguearse", + "Logout": "logout", + "Sort": "sort", + "Register": "register", + "Enter": "entrar", + + "LoggedIn": "logged in", + "NotLoggedIn": "Not logged in", + "LogIn": "log in", + "GoBack": "Go back", + + "SortRecentPosts": "recent posts", + "SortRecentThreads": "most recent threads", + + "LoginNoAccount": "¿No tienes una cuenta? <a href='/register'>Registra</a> una. ", + "LoginFailure": "<b>Failed login attempt:</b> incorrect password, wrong username, or a non-existent user.", + "LoginAlreadyLoggedIn": `You are already logged in. Would you like to <a href="/logout">log out</a>?`, + + "Username": "usuarie", + "Current": "current", + "New": "new", + "GenerateNewKeypair": "I also want to generate a new keypair", + "ChangePasswordDescription": "Use this page to change your password. If needed, you can also regenerate your password reset keypair—used to reset a forgotten password without admin help.", + "Password": "contraseña", + "PasswordMin": "Debe tener por lo menos 9 caracteres.", + "PasswordForgot": "Olvidaste tu contraseña?", + + "Threads": "threads", + "ThreadNew": "new thread", + "ThreadThe": "the thread", + "Index": "index", + + "ThreadCreate": "Create thread", + "Title": "Title", + "Content": "Content", + "Create": "Create", + "TextareaPlaceholder": "Tabula rasa", + + "PasswordReset": "reset password", + "PasswordResetMessage": "You are logged in, log out to reset password using proof", + "PasswordResetSuccess": "Reset password—success!", + "PasswordResetSuccessMessage": "You reset your password!", + "PasswordResetSuccessLinkMessage": "Give it a try and", + + "RegisterMessage": "You already have an account (you are logged in with it).", + "RegisterLinkMessage": "Visit the", + "RegisterSuccess": "registered successfully", + + "ErrUnaccepted": "Unaccepted request", + "ErrThread404": "Thread not found", + "ErrThread404Message": "The thread does not exist (anymore?)", + "ErrGeneric404": "Page not found", + "ErrGeneric404Message": "The visited page does not exist (anymore?). Error code %d.", + + "NewThreadMessage": "Only members of this forum may create new threads", + "NewThreadLinkMessage": "If you are a member,", + "NewThreadCreateError": "Error creating thread", + "NewThreadCreateErrorMessage": "There was a database error when creating the thread, apologies.", + "ThreadStartNew": "Start a new thread", + + "AriaPostMeta": "Post meta", + "AriaDeletePost": "Delete this post", + "AriaRespondIntoThread": "Respond into this thread", + "AriaHome": "Home", + "PromptDeleteQuestion": "Delete post for all posterity?", + "Delete": "delete", + "Post": "post", + "Author": "Author", + "Responded": "responded", + "YourAnswer": "Your answer", + + "RegisterHTMLMessage": `You now have an account! Welcome. Visit the <a href="/">index</a> to read and reply to threads, or start a new one.`, + "RegisterKeypairExplanationStart": `There's just one more thing: <b>save the key displayed below</b>. It is a <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">keypair</a> describing your forum identity, with a private part that only you know; the forum only stores the public portion.`, + "RegisterViewKeypairExplanationEnd": `With this keypair you will be able to reset your account if you ever lose your password—and without having to share your email (or require email infrastructure on the forum's part).`, + "RegisterKeypairWarning": "This keypair will only be displayed once", + + "RegisterVerificationCode": "Your verification code is", + "RegisterVerificationInstructionsTitle": "Verification instructions", + "RegisterVerificationLink": "Verification link", + "RegisterConductCodeBoxOne": `I have refreshed my memory of the <a target="_blank" href="{{ .Data.Link }}">{{ .Data.Name }} Code of Conduct</a>`, + "RegisterConductCodeBoxTwo": `Yes, I have actually <a target="_blank" href="{{ .Data.Link }}">read it</a>`, + + "PasswordResetDescription": "On this page we'll go through a few steps to securely reset your password—without resorting to any emails!", + "PasswordResetUsernameQuestion": "First up: what was your username?", + "PasswordResetCopyPayload": `Now, first copy the snippet (aka <i>proof payload</i>) below`, + "PasswordResetFollowToolInstructions": `Follow the <b>tool instructions</b> to finalize the password reset.`, + "ToolInstructions": `tool instructions`, + "PasswordResetToolInstructions": fmt.Sprintf(` + <ul> + <li><a href="%s">Download the tool</a></li> + <li>Run as:<br><code>pwtool --payload &lt;proof payload from above&gt; --keypair &lt;path to file with yr keypair from registration&gt;</code> + </li> + <li>Copy the generated proof and paste below</li> + <li>(Remember to save your password :)</li> + </ul> + `, toolURL), + "GeneratePayload": "generate payload", + "Proof": "proof", + "NewPassword": "new password", + "ChangePassword": "change password", +} + +var translations = map[string]map[string]string{ + "English": English, + "EspañolMexicano": EspanolMexicano, + "Swedish": Swedish, +} + +type TranslationData struct { + Data interface{} +} + +func (tr *Translator) TranslateWithData(key string, data TranslationData) string { + phrase := translations[tr.Language][key] + t, err := template.New(key).Parse(phrase) + ed := util.Describe("i18n translation") + ed.Check(err, "parse translation phrase") + sb := new(strings.Builder) + err = t.Execute(sb, data) + ed.Check(err, "execute template with data") + return sb.String() +} + +func (tr *Translator) Translate(key string) string { + var empty TranslationData + return tr.TranslateWithData(key, empty) +} + +type Translator struct { + Language string +} + +func Init(lang string) Translator { + if _, ok := translations[lang]; !ok { + log.Fatalln(fmt.Sprintf("language '%s' is not translated yet", lang)) + } + return Translator{lang} +} + +// usage: +// tr := Init("EnglishSwedish") +// fmt.Println(tr.Translate("LoginNoAccount")) +// fmt.Println(tr.TranslateWithData("LoginDescription", Community{"Merveilles", "https://merveill.es"})) diff --git a/run.go b/run.go @@ -36,17 +36,27 @@ func main() { // TODO (2022-01-10): somehow continually update veri sites by scraping merveilles webring sites || webring domain var allowlistLocation string var sessionKey string + var configPath string + var dataDir string var dev bool flag.BoolVar(&dev, "dev", false, "trigger development mode") flag.StringVar(&allowlistLocation, "allowlist", "", "domains which can be used to read verification codes from during registration") flag.StringVar(&sessionKey, "authkey", "", "session cookies authentication key") + flag.StringVar(&configPath, "config", "cerca.toml", "config and settings file containing cerca's customizations") + flag.StringVar(&dataDir, "data", "./data", "directory where cerca will dump its database") flag.Parse() if len(sessionKey) == 0 { complain("please pass a random session auth key with --authkey") } else if len(allowlistLocation) == 0 { complain("please pass a file containing the verification code domain allowlist") } + + err := os.MkdirAll(dataDir, 0750) + if err != nil { + complain(fmt.Sprintf("couldn't create dir '%s'", dataDir)) + } allowlist := readAllowlist(allowlistLocation) allowlist = append(allowlist, "merveilles.town") - server.Serve(allowlist, sessionKey, dev) + config := util.ReadConfig(configPath) + server.Serve(allowlist, sessionKey, dev, dataDir, config) } diff --git a/server/server.go b/server/server.go @@ -12,13 +12,18 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" + "syscall" "time" "cerca/crypto" "cerca/database" + "cerca/defaults" cercaHTML "cerca/html" + "cerca/i18n" "cerca/server/session" + "cerca/types" "cerca/util" "github.com/carlmjohnson/requests" @@ -31,6 +36,7 @@ type TemplateData struct { QuickNav bool LoggedIn bool // TODO (2022-01-09): put this in a middleware || template function or sth? LoggedInID int + ForumName string Title string } @@ -41,8 +47,8 @@ type PasswordResetData struct { } type ChangePasswordData struct { - Action string - Keypair string + Action string + Keypair string } type IndexData struct { @@ -58,8 +64,11 @@ type GenericMessageData struct { } type RegisterData struct { - VerificationCode string - ErrorMessage string + VerificationCode string + ErrorMessage string + Rules template.HTML + VerificationInstructions template.HTML + ConductLink string } type RegisterSuccessData struct { @@ -77,9 +86,13 @@ type ThreadData struct { } type RequestHandler struct { - db *database.DB - session *session.Session - allowlist []string // allowlist of domains valid for forum registration + db *database.DB + session *session.Session + allowlist []string // allowlist of domains valid for forum registration + files map[string][]byte + config types.Config + translator i18n.Translator + templates *template.Template } var developing bool @@ -112,8 +125,15 @@ func (h RequestHandler) IsLoggedIn(req *http.Request) (bool, int) { return true, userid } -var ( - templateFuncs = template.FuncMap{ +// establish closure over config + translator so that it's present in templates during render +func generateTemplates(config types.Config, translator i18n.Translator) (*template.Template, error) { + // only read logo contents once when generating + logo, err := os.ReadFile(config.Documents.LogoPath) + util.Check(err, "generate-template: dump logo") + templateFuncs := template.FuncMap{ + "dumpLogo": func() template.HTML { + return template.HTML(logo) + }, "formatDateTime": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") }, @@ -132,14 +152,32 @@ var ( } return t.Format("2006-01-02") }, + "translate": func(key string) string { + return translator.Translate(key) + }, + "translateWithData": func(key string) string { + data := struct { + Name string + Link string + }{ + Name: config.Community.Name, + Link: config.Community.ConductLink, + } + return translator.TranslateWithData(key, i18n.TranslationData{data}) + }, + "capitalize": util.Capitalize, + "tohtml": func(s string) template.HTML { + // use of this function is risky cause it interprets the passed in string and renders it as unescaped html. + // can allow for attacks! + // + // advice: only use on strings that come statically from within cerca code, never on titles that may contain user-submitted data + // :) + return (template.HTML)(s) + }, } - - templates = template.Must(generateTemplates()) -) - -func generateTemplates() (*template.Template, error) { views := []string{ "about", + "about-template", "footer", "generic-message", "head", @@ -173,9 +211,21 @@ func (h RequestHandler) renderView(res http.ResponseWriter, viewName string, dat data.Title = strings.ReplaceAll(viewName, "-", " ") } + if h.config.Community.Name != "" { + data.ForumName = h.config.Community.Name + } + if data.ForumName == "" { + data.ForumName = "Forum" + } + view := fmt.Sprintf("%s.html", viewName) - if err := templates.ExecuteTemplate(res, view, data); err != nil { - util.Check(err, "rendering %q view", view) + if err := h.templates.ExecuteTemplate(res, view, data); err != nil { + if errors.Is(err, syscall.EPIPE) { + fmt.Println("recovering from broken pipe") + return + } else { + util.Check(err, "rendering %q view", view) + } } } @@ -184,10 +234,10 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) loggedIn, userid := h.IsLoggedIn(req) if !ok { - title := "Thread not found" + title := h.translator.Translate("ErrThread404") data := GenericMessageData{ Title: title, - Message: "The thread does not exist (anymore?)", + Message: h.translator.Translate("ErrThread404Message"), } h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn}) return @@ -212,9 +262,15 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) // TODO (2022-01-07): // * handle error thread := h.db.GetThread(threadid) + pattern := regexp.MustCompile("<img") // markdownize content (but not title) for i, post := range thread { - thread[i].Content = util.Markup(post.Content) + content := []byte(util.Markup(post.Content)) + // make sure images are lazy loaded + if pattern.Match(content) { + content = pattern.ReplaceAll(content, []byte(`<img loading="lazy"`)) + } + thread[i].Content = template.HTML(content) } data := ThreadData{Posts: thread, ThreadURL: req.URL.Path} view := TemplateData{Data: &data, QuickNav: loggedIn, LoggedIn: loggedIn, LoggedInID: userid} @@ -226,10 +282,10 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) } func (h RequestHandler) ErrorRoute(res http.ResponseWriter, req *http.Request, status int) { - title := "Page not found" + title := h.translator.Translate("ErrGeneric404") data := GenericMessageData{ Title: title, - Message: fmt.Sprintf("The visited page does not exist (anymore?). Error code %d.", status), + Message: fmt.Sprintf(h.translator.Translate("ErrGeneric404Message"), status), } h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) } @@ -250,7 +306,7 @@ func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { } // show index listing threads := h.db.ListThreads(mostRecentPost) - view := TemplateData{Data: IndexData{threads}, LoggedIn: loggedIn, Title: "threads"} + view := TemplateData{Data: IndexData{threads}, LoggedIn: loggedIn, Title: h.translator.Translate("Threads")} h.renderView(res, "index", view) } @@ -271,7 +327,7 @@ func (h RequestHandler) LoginRoute(res http.ResponseWriter, req *http.Request) { loggedIn, _ := h.IsLoggedIn(req) switch req.Method { case "GET": - h.renderView(res, "login", TemplateData{Data: LoginData{}, LoggedIn: loggedIn, Title: ""}) + h.renderView(res, "login", TemplateData{Data: LoginData{}, LoggedIn: loggedIn, Title: h.translator.Translate("Login")}) case "POST": username := req.PostFormValue("username") password := req.PostFormValue("password") @@ -283,7 +339,7 @@ func (h RequestHandler) LoginRoute(res http.ResponseWriter, req *http.Request) { } if err != nil { fmt.Println(err) - h.renderView(res, "login", TemplateData{Data: LoginData{FailedAttempt: true}, LoggedIn: loggedIn, Title: ""}) + h.renderView(res, "login", TemplateData{Data: LoginData{FailedAttempt: true}, LoggedIn: loggedIn, Title: h.translator.Translate("Login")}) return } // save user id in cookie @@ -312,108 +368,111 @@ func hasVerificationCode(link, verification string) bool { } func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http.Request) { + // TODO (2022-10-24): add translations for change password view + title := h.translator.Translate("ChangePassword") renderErr := func(errFmt string, args ...interface{}) { errMessage := fmt.Sprintf(errFmt, args...) fmt.Println(errMessage) data := GenericMessageData{ - Title: "Change password", + Title: title, Message: errMessage, Link: "/reset", - LinkText: "Go back", + LinkText: h.translator.Translate("GoBack"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: "change password"}) - } - _, uid := h.IsLoggedIn(req) - - ed := util.Describe("change password") - switch req.Method { - case "GET": - switch req.URL.Path { - default: - h.renderView(res, "change-password", TemplateData{LoggedIn: true, Data: ChangePasswordData{Action: "/reset/submit"}}) - } - case "POST": - switch req.URL.Path { - case "/reset/submit": - oldPassword := req.PostFormValue("password-old") - newPassword := req.PostFormValue("password-new") - resetKeypair := (req.PostFormValue("reset-keypair") == "true") - var keypairString string - - // check if we're resetting keypair - if resetKeypair { - // if so: generate new keypair - kp, err := crypto.GenerateKeypair() - ed.Check(err, "generate keypair") - kpBytes, err := kp.Marshal() - ed.Check(err, "marshal keypair") - pubkey, err := kp.PublicString() - ed.Check(err, "get pubkey string") - // and set it in db - err = h.db.SetPubkey(uid, pubkey) - ed.Check(err, "set new pubkey in database") - keypairString = string(kpBytes) - } - - // check that the submitted, old password is valid - username, err := h.db.GetUsername(uid) - if err != nil { - dump(ed.Eout(err, "get username")) - return - } - - pwhashOld, _, err := h.db.GetPasswordHash(username) - if err != nil { - dump(ed.Eout(err, "get old password hash")) - return - } - - oldPasswordValid := crypto.ValidatePasswordHash(oldPassword, pwhashOld) - if !oldPasswordValid { - renderErr("old password did not match what was in database; not changing password") - return - } - - // let's set the new password in the database. first, hash it - pwhashNew, err := crypto.HashPassword(newPassword) - if err != nil { - dump(ed.Eout(err, "hash new password")) - return - } - // then save the hash - h.db.UpdateUserPasswordHash(uid, pwhashNew) - // render a success message & show a link to the login page :') - h.renderView(res, "change-password-success", TemplateData{LoggedIn: true, Data: ChangePasswordData{Keypair: keypairString}}) - default: - fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) - IndexRedirect(res, req) - } - default: - fmt.Println("non get/post method, redirecting to index") - IndexRedirect(res, req) - } + h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + } + _, uid := h.IsLoggedIn(req) + + ed := util.Describe("change password") + switch req.Method { + case "GET": + switch req.URL.Path { + default: + h.renderView(res, "change-password", TemplateData{LoggedIn: true, Data: ChangePasswordData{Action: "/reset/submit"}}) + } + case "POST": + switch req.URL.Path { + case "/reset/submit": + oldPassword := req.PostFormValue("password-old") + newPassword := req.PostFormValue("password-new") + resetKeypair := (req.PostFormValue("reset-keypair") == "true") + var keypairString string + + // check if we're resetting keypair + if resetKeypair { + // if so: generate new keypair + kp, err := crypto.GenerateKeypair() + ed.Check(err, "generate keypair") + kpBytes, err := kp.Marshal() + ed.Check(err, "marshal keypair") + pubkey, err := kp.PublicString() + ed.Check(err, "get pubkey string") + // and set it in db + err = h.db.SetPubkey(uid, pubkey) + ed.Check(err, "set new pubkey in database") + keypairString = string(kpBytes) + } + + // check that the submitted, old password is valid + username, err := h.db.GetUsername(uid) + if err != nil { + dump(ed.Eout(err, "get username")) + return + } + + pwhashOld, _, err := h.db.GetPasswordHash(username) + if err != nil { + dump(ed.Eout(err, "get old password hash")) + return + } + + oldPasswordValid := crypto.ValidatePasswordHash(oldPassword, pwhashOld) + if !oldPasswordValid { + renderErr("old password did not match what was in database; not changing password") + return + } + + // let's set the new password in the database. first, hash it + pwhashNew, err := crypto.HashPassword(newPassword) + if err != nil { + dump(ed.Eout(err, "hash new password")) + return + } + // then save the hash + h.db.UpdateUserPasswordHash(uid, pwhashNew) + // render a success message & show a link to the login page :') + h.renderView(res, "change-password-success", TemplateData{LoggedIn: true, Data: ChangePasswordData{Keypair: keypairString}}) + default: + fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) + IndexRedirect(res, req) + } + default: + fmt.Println("non get/post method, redirecting to index") + IndexRedirect(res, req) + } } func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Request) { ed := util.Describe("password proof route") - loggedIn, _ := h.IsLoggedIn(req) + loggedIn, _ := h.IsLoggedIn(req) + title := util.Capitalize(h.translator.Translate("PasswordReset")) - // change password functionality, handle this in another function - if loggedIn { - h.handleChangePassword(res, req) - return - } + // change password functionality, handle this in another function + if loggedIn { + h.handleChangePassword(res, req) + return + } renderErr := func(errFmt string, args ...interface{}) { errMessage := fmt.Sprintf(errFmt, args...) fmt.Println(errMessage) data := GenericMessageData{ - Title: "Reset password", + Title: title, Message: errMessage, Link: "/reset", - LinkText: "Go back", + LinkText: h.translator.Translate("GoBack"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: "password reset"}) + h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) } switch req.Method { @@ -503,13 +562,13 @@ func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Re h.db.UpdateUserPasswordHash(userid, pwhash) // render a success message & show a link to the login page :') data := GenericMessageData{ - Title: "Reset password—success!", - Message: "You reset your password!", + Title: h.translator.Translate("PasswordResetSuccess"), + Message: h.translator.Translate("PasswordResetSuccessMessage"), Link: "/login", - LinkMessage: "Give it a try and", - LinkText: "login", + LinkMessage: h.translator.Translate("PasswordResetSuccessLinkMessage"), + LinkText: h.translator.Translate("Login"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: "password reset"}) + h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) default: fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) IndexRedirect(res, req) @@ -524,22 +583,26 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request ed := util.Describe("register route") loggedIn, _ := h.IsLoggedIn(req) if loggedIn { + // TODO (2022-09-20): translate data := GenericMessageData{ - Title: "Register", - Message: "You already have an account (you are logged in with it).", + Title: util.Capitalize(h.translator.Translate("Register")), + Message: h.translator.Translate("RegisterMessage"), Link: "/", - LinkMessage: "Visit the", - LinkText: "index", + LinkMessage: h.translator.Translate("RegisterLinkMessage"), + LinkText: h.translator.Translate("Index"), } - h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn, Title: "register"}) + h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn, Title: h.translator.Translate("Register")}) return } + rules := util.Markup(template.HTML(h.files["rules"])) + verification := util.Markup(template.HTML(h.files["verification-instructions"])) + conduct := h.config.Community.ConductLink var verificationCode string renderErr := func(errFmt string, args ...interface{}) { errMessage := fmt.Sprintf(errFmt, args...) fmt.Println(errMessage) - h.renderView(res, "register", TemplateData{Data: RegisterData{verificationCode, errMessage}}) + h.renderView(res, "register", TemplateData{Data: RegisterData{verificationCode, errMessage, rules, verification, conduct}}) } var err error @@ -549,14 +612,15 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request verificationCode, err = h.session.GetVerificationCode(req) // we had an error getting the verification code, generate a code and set it on the session if err != nil { - verificationCode = fmt.Sprintf("MRV%06d\n", crypto.GenerateVerificationCode()) + prefix := util.VerificationPrefix(h.config.Community.Name) + verificationCode = fmt.Sprintf("%s%06d\n", prefix, crypto.GenerateVerificationCode()) err = h.session.SaveVerificationCode(req, res, verificationCode) if err != nil { renderErr("Had troubles setting the verification code on session") return } } - h.renderView(res, "register", TemplateData{Data: RegisterData{verificationCode, ""}}) + h.renderView(res, "register", TemplateData{Data: RegisterData{verificationCode, "", rules, verification, conduct}}) case "POST": verificationCode, err = h.session.GetVerificationCode(req) if err != nil { @@ -630,13 +694,14 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request } kpJson, err := keypair.Marshal() ed.Check(err, "marshal keypair") - h.renderView(res, "register-success", TemplateData{Data: RegisterSuccessData{string(kpJson)}, LoggedIn: loggedIn, Title: "registered successfully"}) + h.renderView(res, "register-success", TemplateData{Data: RegisterSuccessData{string(kpJson)}, LoggedIn: loggedIn, Title: h.translator.Translate("RegisterSuccess")}) default: fmt.Println("non get/post method, redirecting to index") IndexRedirect(res, req) } } +// purely an example route; intentionally unused :) func (h RequestHandler) GenericRoute(res http.ResponseWriter, req *http.Request) { data := GenericMessageData{ Title: "GenericTitle", @@ -650,7 +715,8 @@ func (h RequestHandler) GenericRoute(res http.ResponseWriter, req *http.Request) func (h RequestHandler) AboutRoute(res http.ResponseWriter, req *http.Request) { loggedIn, _ := h.IsLoggedIn(req) - h.renderView(res, "about", TemplateData{LoggedIn: loggedIn}) + input := util.Markup(template.HTML(h.files["about"])) + h.renderView(res, "about-template", TemplateData{Data: input, LoggedIn: loggedIn, Title: h.translator.Translate("About")}) } func (h RequestHandler) RobotsRoute(res http.ResponseWriter, req *http.Request) { @@ -662,19 +728,20 @@ func (h RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reques switch req.Method { // Handle GET (=> want to start a new thread) case "GET": + // TODO (2022-09-20): translate if !loggedIn { - title := "Not logged in" + title := h.translator.Translate("NotLoggedIn") data := GenericMessageData{ Title: title, - Message: "Only members of this forum may create new threads", + Message: h.translator.Translate("NewThreadMessage"), Link: "/login", - LinkMessage: "If you are a member,", - LinkText: "log in", + LinkMessage: h.translator.Translate("NewThreadLinkMessage"), + LinkText: h.translator.Translate("LogIn"), } h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) return } - h.renderView(res, "new-thread", TemplateData{LoggedIn: loggedIn, Title: "new thread"}) + h.renderView(res, "new-thread", TemplateData{LoggedIn: loggedIn, Title: h.translator.Translate("ThreadNew")}) case "POST": // Handle POST (=> title := req.PostFormValue("title") @@ -684,10 +751,10 @@ func (h RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reques threadid, err := h.db.CreateThread(title, content, userid, 1) if err != nil { data := GenericMessageData{ - Title: "Error creating thread", - Message: "There was a database error when creating the thread, apologies.", + Title: h.translator.Translate("NewThreadCreateError"), + Message: h.translator.Translate("NewThreadCreateErrorMessage"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: "new thread"}) + h.renderView(res, "generic-message", TemplateData{Data: data, Title: h.translator.Translate("ThreadNew")}) return } // when data has been stored => redirect to thread @@ -710,10 +777,10 @@ func (h RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Reque // generic error message base, with specifics being swapped out depending on the error genericErr := GenericMessageData{ - Title: "Unaccepted request", - LinkMessage: "Go back to", + Title: h.translator.Translate("ErrUnaccepted"), + LinkMessage: h.translator.Translate("GoBack"), Link: threadURL, - LinkText: "the thread", + LinkText: h.translator.Translate("ThreadThe"), } renderErr := func(msg string) { @@ -752,17 +819,15 @@ func (h RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Reque http.Redirect(res, req, threadURL, http.StatusSeeOther) } -func Serve(allowlist []string, sessionKey string, isdev bool) { +func Serve(allowlist []string, sessionKey string, isdev bool, dir string, conf types.Config) { port := ":8272" - dir := "./data/" if isdev { developing = true - dir = "./testdata/" port = ":8277" } - forum, err := NewServer(allowlist, sessionKey, dir) + forum, err := NewServer(allowlist, sessionKey, dir, conf) if err != nil { util.Check(err, "instantiate CercaForum") } @@ -799,18 +864,43 @@ func (u *CercaForum) directory() string { // NewServer sets up a new CercaForum object. Always use this to initialize // new CercaForum objects. Pass the result to http.Serve() with your choice // of net.Listener. -func NewServer(allowlist []string, sessionKey, dir string) (*CercaForum, error) { +func NewServer(allowlist []string, sessionKey, dir string, config types.Config) (*CercaForum, error) { s := &CercaForum{ - ServeMux: http.ServeMux{}, + ServeMux: http.ServeMux{}, Directory: dir, } dbpath := filepath.Join(s.directory(), "forum.db") db := database.InitDB(dbpath) + config.EnsureDefaultPaths() + // load the documents specified in the config + // iff document doesn't exist, dump a default document where it should be and read that + type triple struct{ key, docpath, content string } + triples := []triple{ + {"about", config.Documents.AboutPath, defaults.DEFAULT_ABOUT}, + {"rules", config.Documents.RegisterRulesPath, defaults.DEFAULT_RULES}, + {"verification-instructions", config.Documents.VerificationExplanationPath, defaults.DEFAULT_VERIFICATION}, + {"logo", config.Documents.LogoPath, defaults.DEFAULT_LOGO}, + } + + files := make(map[string][]byte) + for _, t := range triples { + data, err := util.LoadFile(t.key, t.docpath, t.content) + if err != nil { + return s, err + } + files[t.key] = data + } + + // TODO (2022-10-20): when receiving user request, inspect user-agent language and change language from server default + // for currently translated languages, see i18n/i18n.go + translator := i18n.Init(config.Community.Language) + templates := template.Must(generateTemplates(config, translator)) + handler := RequestHandler{&db, session.New(sessionKey, developing), allowlist, files, config, translator, templates} + /* note: be careful with trailing slashes; go's default handler is a bit sensitive */ // TODO (2022-01-10): introduce middleware to make sure there is never an issue with trailing slashes - handler := RequestHandler{&db, session.New(sessionKey, developing), allowlist} s.ServeMux.HandleFunc("/reset/", handler.ResetPasswordRoute) s.ServeMux.HandleFunc("/about", handler.AboutRoute) s.ServeMux.HandleFunc("/logout", handler.LogoutRoute) diff --git a/types/types.go b/types/types.go @@ -0,0 +1,51 @@ +package types + +import ( + "path/filepath" +) + +type Config struct { + Community struct { + Name string `json:"name"` + ConductLink string `json:"conduct_url"` // optional + Language string `json:"language"` + } `json:"general"` + + Documents struct { + LogoPath string `json:"logo"` + AboutPath string `json:"about"` + RegisterRulesPath string `json:"rules"` + VerificationExplanationPath string `json:"verification_instructions"` + } `json:"documents"` +} + +// Ensure that, at the very least, default paths exist for each expected document path. Does not overwrite previously set values. +func (c *Config) EnsureDefaultPaths() { + if c.Documents.AboutPath == "" { + c.Documents.AboutPath = filepath.Join("content", "about.md") + } + if c.Documents.RegisterRulesPath == "" { + c.Documents.RegisterRulesPath = filepath.Join("content", "rules.md") + } + if c.Documents.VerificationExplanationPath == "" { + c.Documents.VerificationExplanationPath = filepath.Join("content", "verification-instructions.md") + } + if c.Documents.LogoPath == "" { + c.Documents.LogoPath = filepath.Join("content", "logo.html") + } +} + +/* +config structure: +["general"] +name = "Merveilles" +conduct_link = "https://github.com/merveilles/Resources/blob/master/CONDUCT.md" +language = "English" + + +["documents"] +logo = "./logo.svg" +about = "./about.md" +rules = "./rules.md" +verification_instructions = "./verification-instructions.md" +*/ diff --git a/util/util.go b/util/util.go @@ -1,19 +1,30 @@ package util import ( + "bytes" "encoding/base64" "encoding/hex" + "encoding/json" + "errors" "fmt" "html/template" + "io/fs" "log" "net/http" "net/url" + "os" + "path/filepath" + "regexp" "strconv" "strings" "github.com/gomarkdown/markdown" + "github.com/komkom/toml" "github.com/microcosm-cc/bluemonday" - // "errors" + "golang.org/x/exp/utf8string" + + "cerca/defaults" + "cerca/types" ) /* util.Eout example invocations @@ -95,6 +106,16 @@ func SanitizeStringStrict(s string) string { return strictContentGuardian.Sanitize(s) } +func VerificationPrefix(name string) string { + pattern := regexp.MustCompile("A|E|O|U|I|Y") + upper := strings.ToUpper(name) + replaced := string(pattern.ReplaceAll([]byte(upper), []byte(""))) + if len(replaced) < 3 { + replaced += "XYZ" + } + return replaced[0:3] +} + func GetThreadSlug(threadid int, title string, threadLen int) string { return fmt.Sprintf("/thread/%d/%s-%d/", threadid, SanitizeURL(title), threadLen) } @@ -130,3 +151,65 @@ func GetURLPortion(req *http.Request, index int) (int, bool) { } return desiredID, true } + +func Capitalize(s string) string { + // utf8 safe capitalization + str := utf8string.NewString(s) + first := string(str.At(0)) + rest := string(str.Slice(1, str.RuneCount())) + return strings.ToUpper(first) + rest +} + +func CreateIfNotExist(docpath, content string) (bool, error) { + err := os.MkdirAll(filepath.Dir(docpath), 0750) + if err != nil { + return false, err + } + _, err = os.Stat(docpath) + if err != nil { + // if the file doesn't exist, create it + if errors.Is(err, fs.ErrNotExist) { + err = os.WriteFile(docpath, []byte(content), 0777) + if err != nil { + return false, err + } + // created file successfully + return true, nil + } else { + return false, err + } + } + return false, nil +} + +func ReadConfig(confpath string) types.Config { + ed := Describe("config") + _, err := CreateIfNotExist(confpath, defaults.DEFAULT_CONFIG) + ed.Check(err, "create default config") + + data, err := os.ReadFile(confpath) + ed.Check(err, "read file") + + var conf types.Config + decoder := json.NewDecoder(toml.New(bytes.NewBuffer(data))) + + err = decoder.Decode(&conf) + ed.Check(err, "decode toml with json decoder") + + return conf +} + +func LoadFile(key, docpath, defaultContent string) ([]byte, error) { + ed := Describe("load file") + _, err := CreateIfNotExist(docpath, defaultContent) + err = ed.Eout(err, "create if not exist (%s) %s", key, docpath) + if err != nil { + return nil, err + } + data, err := os.ReadFile(docpath) + err = ed.Eout(err, "read %s", docpath) + if err != nil { + return nil, err + } + return data, nil +}