cerca

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

commit 53dd63fbc59fb6690b25168675b6fc10e62adb42
parent d792585058e20eaf11d6aeec0ef8a7b582882437
Author: cblgh <cblgh@cblgh.org>
Date:   Wed, 21 Sep 2022 16:21:01 +0200

continue i18n work; begin customization work

Diffstat:
Adefaults/defaults.go | 20++++++++++++++++++++
Adefaults/sample-about.md | 0
Adefaults/sample-config.toml | 0
Adefaults/sample-logo.svg | 0
Adefaults/sample-rules.md | 0
Adefaults/sample-verification-instructions.md | 0
Mgo.mod | 1+
Mgo.sum | 5+++++
Mhtml/head.html | 13+++++++------
Mhtml/index.html | 2+-
Mhtml/login.html | 6+++---
Mhtml/new-thread.html | 10+++++-----
Mhtml/password-reset.html | 28+++++++++++-----------------
Mhtml/register-success.html | 13+++++--------
Mhtml/register.html | 39+++++++++++++--------------------------
Mhtml/thread.html | 21++++++++++-----------
Mi18n/i18n.go | 211++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mrun.go | 5++++-
Mserver/server.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Atypes/types.go | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mutil/util.go | 35++++++++++++++++++++++++++++++++++-
21 files changed, 437 insertions(+), 126 deletions(-)

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.svg +var DEFAULT_LOG 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 diff --git a/defaults/sample-config.toml b/defaults/sample-config.toml diff --git a/defaults/sample-logo.svg b/defaults/sample-logo.svg diff --git a/defaults/sample-rules.md b/defaults/sample-rules.md diff --git a/defaults/sample-verification-instructions.md b/defaults/sample-verification-instructions.md diff --git a/go.mod b/go.mod @@ -6,6 +6,7 @@ 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 diff --git a/go.sum b/go.sum @@ -15,15 +15,20 @@ 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= 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 */ @@ -183,7 +183,7 @@ </head> <body> <header> - <a style="margin-bottom: 0; height: 48px;" href="/" aria-label="Home"> + <a style="margin-bottom: 0; height: 48px;" href="/" aria-label='{{ "AriaHome" | translate }}'> <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 "/> @@ -194,13 +194,14 @@ <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> 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.html b/html/login.html @@ -1,15 +1,15 @@ {{ template "head" . }} <main> <h1>{{ "Login" | translate | capitalize }}</h1> - <p>{{ "LoginDescription" | translateWithData | capitalize | tohtml }} {{ "LoginNoAccount" | translate | tohtml }}</p> + <p>{{ "ForumDescription" | translateWithData | tohtml }} {{ "LoginNoAccount" | translate | tohtml }}</p> <div style="max-width: 20rem"> {{ template "login-component" . }} <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,36 @@ {{ 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> + <p>{{ "ForumDescription" | translateWithData | tohtml}} {{ "RegisterRules" | translateWithData | tohtml }}.</p> + <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 changing the translations --> + {{ "RegisterVerificationInstructions" | translate | tohtml }} </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}}"> <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" | translate | tohtml }}</label> </div> </div> - <input type="submit" value="Register"> + <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,20 +5,19 @@ {{ $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;"> @@ -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 @@ -5,38 +5,235 @@ import ( "html/template" "strings" "log" + "fmt" ) +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", + "SortPostsRecent": "recent posts", "SortThreadsRecent": "most recent threads", - "LoginDescription": "This forum is for the <a href='{{ .CommunityLink }}'>{{.CommunityName}}</a> community.", + + "ForumDescription": "This forum is for the <a href='{{ .CommunityLink }}'>{{.CommunityName}}</a> community.", "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", "Password": "password", "PasswordMin": "Must be at least 9 characters long", "PasswordForgot": "Forgot your password?", - "Enter": "enter", -} + + "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", + + "RegisterRules": `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>`, + + "RegisterVerificationCode": "Your verification code is", + "RegisterVerificationInstructionsTitle": "Verification instructions", + // TODO (2022-09-20): make verification instructions another md file to load, pass path from config + "RegisterVerificationInstructions": `<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> + `, + + "RegisterVerificationLink": "Verification link", + "RegisterConductCodeBoxOne": `I have refreshed my memory of the <a target="_blank" href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">{{ .CommunityName }} Code of Conduct</a>`, + "RegisterConductCodeBoxTwo": `Yes, I have actually <a target="_blank" href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">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 EspanolMexicano = map[string]string{ "About": "acerca de", "Login": "loguearse", "Logout": "logout", "Sort": "sort", - "SortPostsRecent": "recent posts", - "SortThreadsRecent": "most recent threads", - "LoginDescription": "Este foro es principalmente para las personas de la comunidad <a href='{{ .CommunityLink }}'>{{ .CommunityName }}</a>.", + "Register": "register", + "Enter": "entrar", + + "LoggedIn": "logged in", + "NotLoggedIn": "Not logged in", + "LogIn": "log in", + "GoBack": "Go back", + + "SortRecentPosts": "recent posts", + "SortRecentThreads": "most recent threads", + + "ForumDescription": "Este foro es principalmente para las personas de la comunidad <a href='{{ .CommunityLink }}'>{{ .CommunityName }}</a>.", "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", "Password": "contraseña", "PasswordMin": "Debe tener por lo menos 9 caracteres.", "PasswordForgot": "Olvidaste tu contraseña?", - "Enter": "entrar", + + "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", + + "RegisterRules": `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>`, + + "RegisterVerificationCode": "Your verification code is", + "RegisterVerificationInstructionsTitle": "Verification instructions", + // TODO (2022-09-20): make verification instructions another md file to load, pass path from config + "RegisterVerificationInstructions": `<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> + `, + "RegisterVerificationLink": "Verification link", + "RegisterConductCodeBoxOne": `I have refreshed my memory of the <a target="_blank" href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">{{ .CommunityName }} Code of Conduct</a>`, + "RegisterConductCodeBoxTwo": `Yes, I have actually <a target="_blank" href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">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{ diff --git a/run.go b/run.go @@ -36,10 +36,12 @@ 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 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.Parse() if len(sessionKey) == 0 { complain("please pass a random session auth key with --authkey") @@ -48,5 +50,6 @@ func main() { } allowlist := readAllowlist(allowlistLocation) allowlist = append(allowlist, "merveilles.town") - server.Serve(allowlist, sessionKey, dev) + config := util.ReadConfig(configPath) + server.Serve(allowlist, sessionKey, dev, config) } diff --git a/server/server.go b/server/server.go @@ -21,10 +21,26 @@ import ( cercaHTML "cerca/html" "cerca/server/session" "cerca/util" + "cerca/types" "github.com/carlmjohnson/requests" ) +/* 2022-09-20: customizable stuff +* CommunityName +* CommunityLogo +* CommunityLink +* ForumName +*/ + +// TODO (2022-09-20): make verification instructions another md file to load, pass path from config +/* +* pass in: +* registration rules +* verification instructions +* code of conduct link +*/ + /* TODO (2022-01-03): include csrf token via gorilla, or w/e, when rendering */ type TemplateData struct { @@ -32,6 +48,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 } @@ -79,6 +96,7 @@ type RequestHandler struct { } var developing bool +var config types.Config func dump(err error) { if developing { @@ -137,9 +155,7 @@ var ( "translateWithData": func(key string) string { return translator.TranslateWithData(key, community) }, - "capitalize": func (s string) string { - return strings.ToUpper(string(s[0])) + s[1:] - }, + "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! @@ -188,6 +204,10 @@ func (h RequestHandler) renderView(res http.ResponseWriter, viewName string, dat data.Title = strings.ReplaceAll(viewName, "-", " ") } + 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) @@ -199,10 +219,10 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) loggedIn, userid := h.IsLoggedIn(req) if !ok { - title := "Thread not found" + title := translator.Translate("ErrThread404") data := GenericMessageData{ Title: title, - Message: "The thread does not exist (anymore?)", + Message: translator.Translate("ErrThread404Message"), } h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn}) return @@ -241,10 +261,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 := translator.Translate("ErrGeneric404") data := GenericMessageData{ Title: title, - Message: fmt.Sprintf("The visited page does not exist (anymore?). Error code %d.", status), + Message: fmt.Sprintf(translator.Translate("ErrGeneric404Message"), status), } h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) } @@ -265,7 +285,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: translator.Translate("Threads")} h.renderView(res, "index", view) } @@ -329,14 +349,16 @@ func hasVerificationCode(link, verification string) bool { func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Request) { ed := util.Describe("password proof route") loggedIn, _ := h.IsLoggedIn(req) + title := util.Capitalize(translator.Translate("PasswordReset")) + if loggedIn { data := GenericMessageData{ - Title: "Reset password", - Message: "You are logged in, log out to reset password using proof", + Title: title, + Message: translator.Translate("PasswordResetMessage"), Link: "/logout", - LinkText: "Logout", + LinkText: util.Capitalize(translator.Translate("Logout")), } - h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn, Title: "Reset password"}) + h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn, Title: title}) return } @@ -344,12 +366,12 @@ func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Re errMessage := fmt.Sprintf(errFmt, args...) fmt.Println(errMessage) data := GenericMessageData{ - Title: "Reset password", + Title: title, Message: errMessage, Link: "/reset", - LinkText: "Go back", + LinkText: translator.Translate("GoBack"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: "password reset"}) + h.renderView(res, "generic-message", TemplateData{Data: data, Title: translator.Translate("PasswordReset")}) } switch req.Method { @@ -439,13 +461,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: translator.Translate("PasswordResetSuccess"), + Message: translator.Translate("PasswordResetSuccessMessage"), Link: "/login", - LinkMessage: "Give it a try and", - LinkText: "login", + LinkMessage: translator.Translate("PasswordResetSuccessLinkMessage"), + LinkText: translator.Translate("Login"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: "password reset"}) + h.renderView(res, "generic-message", TemplateData{Data: data, Title: translator.Translate("PasswordReset")}) default: fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) IndexRedirect(res, req) @@ -460,14 +482,15 @@ 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(translator.Translate("Register")), + Message: translator.Translate("RegisterMessage"), Link: "/", - LinkMessage: "Visit the", - LinkText: "index", + LinkMessage: translator.Translate("RegisterLinkMessage"), + LinkText: 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: translator.Translate("Register")}) return } @@ -566,13 +589,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: 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", @@ -590,10 +614,10 @@ func (h RequestHandler) AboutRoute(res http.ResponseWriter, req *http.Request) { // * make sure file exists // * create function to output a prefilled version, using the Community name and CommunityLink // * embed the prefilled version in the code using golang's goembed - b, err := os.ReadFile("data/about.md") + b, err := os.ReadFile("./about.md") util.Check(err, "about route: open about.md") input := util.Markup(template.HTML(b)) - h.renderView(res, "about-template", TemplateData{Data: input, LoggedIn: loggedIn}) + h.renderView(res, "about-template", TemplateData{Data: input, LoggedIn: loggedIn, Title: translator.Translate("About")}) } func (h RequestHandler) RobotsRoute(res http.ResponseWriter, req *http.Request) { @@ -605,19 +629,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 := translator.Translate("NotLoggedIn") data := GenericMessageData{ Title: title, - Message: "Only members of this forum may create new threads", + Message: translator.Translate("NewThreadMessage"), Link: "/login", - LinkMessage: "If you are a member,", - LinkText: "log in", + LinkMessage: translator.Translate("NewThreadLinkMessage"), + LinkText: 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: translator.Translate("ThreadNew")}) case "POST": // Handle POST (=> title := req.PostFormValue("title") @@ -627,10 +652,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: translator.Translate("NewThreadCreateError"), + Message: translator.Translate("NewThreadCreateErrorMessage"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: "new thread"}) + h.renderView(res, "generic-message", TemplateData{Data: data, Title: translator.Translate("ThreadNew")}) return } // when data has been stored => redirect to thread @@ -653,10 +678,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: translator.Translate("ErrUnaccepted"), + LinkMessage: translator.Translate("GoBack"), Link: threadURL, - LinkText: "the thread", + LinkText: translator.Translate("ThreadThe"), } renderErr := func(msg string) { @@ -695,9 +720,10 @@ 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, conf types.Config) { port := ":8272" dir := "./data/" + config = conf if isdev { developing = true diff --git a/types/types.go b/types/types.go @@ -0,0 +1,48 @@ +package types + +type Config struct { + // for internal use + Files map[string]string + // use as: + // config.Files["about"] -> about markdown + // config.Files["rules"] -> rules explanation markdown + // config.Files["verification"] -> verification explanation + + Community struct { + Name string + Link string + ConductLink string + } `json:"general"` + + Theme struct { + Background string + Foreground string + Links string + } `json:"theme"` + + Documents struct { + LogoPath string + AboutPath string + RegisterRulesPath string + VerificationExplanationPath string + } `json:"documents"` +} + +/* +config.Community.Name +config.Community.Link +config.Community.ConductLink + +config structure +["General"] +Name = "Merveilles" +Link = "https://wiki.xxiivv.com/site/merveilles.html" +ConductLink = "https://github.com/merveilles/Resources/blob/master/CONDUCT.md" + + +["Documents"] +LogoPath = "./logo.svg" +AboutPath = "./about.md" +RegisterRulesPath = "./rules.md" +VerificationExplanationPath = "./verification-instructions.md" +*/ diff --git a/util/util.go b/util/util.go @@ -1,8 +1,10 @@ package util import ( + "bytes" "encoding/base64" "encoding/hex" + "encoding/json" "fmt" "html/template" "log" @@ -10,10 +12,14 @@ import ( "net/url" "strconv" "strings" + "os" + "github.com/komkom/toml" "github.com/gomarkdown/markdown" "github.com/microcosm-cc/bluemonday" - // "errors" + + "cerca/types" + "cerca/defaults" ) /* util.Eout example invocations @@ -130,3 +136,30 @@ func GetURLPortion(req *http.Request, index int) (int, bool) { } return desiredID, true } + +func Capitalize (s string) string { + return strings.ToUpper(string(s[0])) + s[1:] +} + +func CheckFileExists(filepath string, defaultContent string) { + // check if file exists + // if it doesn't: + // write the default contents to the filepath +} + +// TODO (2022-09-21): +// * DONE go:embed sample-config.toml ---> defaults.DEFAULT_<x> +// * util.checkFileExists(path, mockContents) +func ReadConfig(confpath string) types.Config { + data, err := os.ReadFile(confpath) + ed := Describe("config") + 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 +}