├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── MIGRATIONS.md
├── README.md
├── cmd
└── cerca
│ ├── admin.go
│ ├── authkey.go
│ ├── main.go
│ ├── migrate.go
│ ├── reset.go
│ ├── user.go
│ └── version.go
├── constants
└── constants.go
├── crypto
└── crypto.go
├── database
├── database.go
├── migrations.go
└── moderation.go
├── defaults
├── defaults.go
├── sample-about.md
├── sample-config.toml
├── sample-logo.html
├── sample-registration-instructions.md
├── sample-rules.md
└── sample-theme.css
├── docs
├── accessibility.md
├── faq.md
└── hosting.md
├── go.mod
├── go.sum
├── html
├── about-template.html
├── about.html
├── account.html
├── admin-add-user.html
├── admin-invites.html
├── admin.html
├── admins-list.html
├── assets
│ ├── favicon.png
│ ├── merveilles.png
│ ├── merveilles.svg
│ └── theme.css
├── change-password-success.html
├── change-password.html
├── edit-post.html
├── footer.html
├── generic-message.html
├── head.html
├── html.go
├── index.html
├── login-component.html
├── login.html
├── moderation-log.html
├── new-thread.html
├── password-reset.html
├── register-success.html
├── register.html
└── thread.html
├── i18n
└── i18n.go
├── limiter
└── limiter.go
├── server
├── account.go
├── moderation.go
├── server.go
├── session
│ └── session.go
└── util.go
├── types
└── types.go
└── util
└── util.go
/.gitignore:
--------------------------------------------------------------------------------
1 | .*.sw[a-z]
2 | *.db
3 | data/
4 | data/.gitkeep
5 | pwtool
6 | admin-reset
7 | *.json
8 | /cerca
9 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Hello! Let's jam! Before that, though, let's slow down and talk about how to make contributions that will be
4 | welcomed with open arms.
5 |
6 | The goal of this forum software is to enable small non-commercial communities to have their own
7 | place for longer-flowing conversations, and to maintain a living history. As part of that, it
8 | should be easy to make this software your own. It should also be easy to host on any kind of
9 | computer, given prior experience with hosting a simple html website. Contradicting this is the
10 | messy reality that the current software is written for the explicit use of the [Merveilles]
11 | community!
12 |
13 | ## Code contributions
14 | In general, it's preferred to keep the code flexible than to impose (unnecessary) hierarchy at
15 | this early stage, and to reach out before you attempt to add anything large-ish. Common sense,
16 | basically.
17 |
18 | In a bit more detail and in bullet-point format—the guiding principles:
19 |
20 | * Communicate _before_ starting large rewrites or big features
21 | * Keep the existing style and organization
22 | * Flexibility before hierarchy
23 | * Have fun! There are other places to execute at a megacorp level
24 | * Additions should benefit long-term use of the forum and longer form conversations
25 | * New features should have a reasonable impact on the codebase
26 | * Said another way: new additions should not have an outsized impact on the _overall_ codebase
27 | * The software should always be easy to host on a variety of devices, from powerful servers to smol memory-drained and storage-depleted computers
28 | * As far as we are able to: avoid client-side javascript
29 | * Said another way: there should always be a way to do something without a functioning javascript engine
30 | * Don't `go fmt` the entire codebase in the same PR as you're adding a feature; do that separately if it's needed
31 | * The maintainer reserves the right to make final decisions, for example regarding whether
32 | something:
33 | * makes the codebase less fun to work with, or understandable
34 | * goes against the project's idea of benefitting conversations
35 | * does not compose well with the existing forum experience
36 |
37 | At the end of the day, a maintainer must live with decisions made for the project—both good and
38 | bad! That weight of responsibility is taken into account when looking at new contributions.
39 |
40 | [Merveilles]: https://now.lectronice.com/notes/merveilles/
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Cerca
2 | Copyright (C) 2021 Alexander Cobleigh & the Cerca Developers
3 |
4 | This program is free software: you can redistribute it and/or modify
5 | it under the terms of the GNU Affero General Public License as
6 | published by the Free Software Foundation, either version 3 of the
7 | License, or (at your option) any later version.
8 |
9 | This program is distributed in the hope that it will be useful,
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | GNU Affero General Public License for more details.
13 |
14 | You should have received a copy of the GNU Affero General Public License
15 | along with this program. If not, see .
16 |
--------------------------------------------------------------------------------
/MIGRATIONS.md:
--------------------------------------------------------------------------------
1 | # Migrations
2 |
3 | This documents migrations for breaking database changes. These are intended to be as few as
4 | possible, but sometimes they are necessary.
5 |
6 | ## [2024-07-20] Private threads
7 |
8 | Add a column to `database.Thread` to signal whether or not the thread is private.
9 |
10 | For more details, see [database/migrations.go](./database/migrations.go).
11 |
12 | Build and then run the migration tool in `cmd/migration-tool` accordingly:
13 |
14 | ```
15 | cd cmd/migration-tool
16 | go build
17 | ./migration-tool --database path-to-your-forum.db --migration 2024-02-thread-private-migration
18 | ```
19 |
20 | ## [2024-01-16] Migrating password hash libraries
21 |
22 | To support 32 bit architectures, such as running Cerca on an older Raspberry Pi, the password
23 | hashing library used previously
24 | [github.com/synacor/argon2id](https://github.com/synacor/argon2id) was swapped out for
25 | [github.com/matthewhartstonge/argon2](https://github.com/matthewhartstonge/argon2).
26 |
27 | The password hashing algorithm remains the same, so users **do not** need to reset their
28 | password, but the old encoding format needed migrating in the database.
29 |
30 | The new library also has a stronger default time parameter, which will be used for new
31 | passwords while old passwords will use the time parameter stored together with their
32 | database record.
33 |
34 | For more details, see [database/migrations.go](./database/migrations.go).
35 |
36 | Build and then run the migration tool in `cmd/migration-tool` accordingly:
37 |
38 | ```
39 | cd cmd/migration-tool
40 | go build
41 | ./migration-tool --database path-to-your-forum.db --migration 2024-01-password-hash-migration
42 | ```
43 |
44 |
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Cerca
2 | _lean forum software_
3 |
4 | Meaning:
5 | * to search, quest, run _(it)_
6 | * near, close, around, nearby, nigh _(es)_
7 | * approximately, roughly _(en; from **circa**)_
8 |
9 | This piece of software was created after a long time of pining for a new wave of forums hangs.
10 | The reason it exists are many. To harbor longer form discussions, and for crawling through
11 | threads and topics. For habitually visiting the site to see if anything new happened, as
12 | opposed to being obtrusively notified when in the middle of something else. For that sweet
13 | tinge of nostalgia that comes with the terrain, from having grown up in pace with the sprawling
14 | phpBB forum communities of the mid noughties.
15 |
16 | It was written for the purpose of powering the nascent [Merveilles community forums](https://forum.merveilles.town).
17 |
18 | ## Features
19 |
20 | * **Customizable**: Many of Cerca's facets are customizable and the structure is intentionally simple to enable DIY modification
21 | * **Private**: Threads are public viewable by default but new threads may be set as private, restricting views to logged-in users only
22 | * **Easy admin**: A simple admin panel lets you add users, reset passwords, and remove old accounts. Impactful actions require two admins to perform, or a week of time to pass without a veto from any admin
23 | * **Invites**: Fully-featured system for creating both one-time and multi-use invites. Admins can monitor invite redemption by batch as well as issue and delete batches of invites. Accessible using the same simple type of web interface that services the rest of the forum's administration tasks.
24 | * **Transparency**: Actions taken by admins are viewable by any logged-in user in the form of a moderation log
25 | * **Low maintenance**: Cerca is architected to minimize maintenance and hosting costs by carefully choosing which features it supports, how they work, and which features are intentionally omitted
26 | * **RSS**: Receive updates when threads are created or new posts are made by subscribing to the forum RSS feed
27 |
28 | ## Usage
29 |
30 | ```
31 | cerca --help
32 |
33 | USAGE:
34 | run the forum
35 |
36 | cerca -authkey "CHANGEME"
37 | cerca -dev
38 |
39 | COMMANDS:
40 | adduser create a new user
41 | makeadmin make an existing user an admin
42 | migrate manage database migrations
43 | resetpw reset a user's password
44 |
45 | OPTIONS:
46 | -authkey string
47 | session cookies authentication key
48 | -config string
49 | config and settings file containing cerca's customizations (default "cerca.toml")
50 | -data string
51 | directory where cerca will dump its database (default "./data")
52 | -dev
53 | trigger development mode
54 | ```
55 |
56 | To execute the other commands, run them as:
57 |
58 | ```
59 | cerca adduser --username ""
60 | ```
61 |
62 | ## Config
63 | Cerca supports community customization.
64 |
65 | * Write a custom [about text](/defaults/sample-about.md) describing the community inhabiting the forum
66 | * Define your own [registration rules](/defaults/sample-rules.md), [instructions on getting an invite code to register](/defaults/sample-registration-instructions.md), and link to an existing code of conduct
67 | * Set your own [custom logo](/defaults/sample-logo.html) (whether svg, png or emoji)
68 | * Create your own theme by writing plain, frameworkless [css](/html/assets/theme.css)
69 |
70 | To enable these customizations, there's a config file. To choose a config file, run cerca with
71 | the `--config` option; the default config file is set to `./cerca.toml`.
72 |
73 | ```
74 | cerca --config ./configs/cerca.toml
75 | ```
76 |
77 | The configuration format is [TOML](https://toml.io/en/) and the config is populated with the following
78 | defaults:
79 |
80 | ```TOML
81 | [general]
82 | name = "" # whatever you want to name your forum; primarily used as display in tab titles
83 | conduct_url = "" # optional + recommended: if omitted, the CoC checkboxes in /register will be hidden
84 | language = "English" # Swedish, English and a few others. contributions for more translations welcome!
85 |
86 | [rss]
87 | feed_name = "" # defaults to [general]'s name if unset
88 | feed_description = ""
89 | forum_url = "" # should be forum index route https://example.com. used to generate post routes for feed, must be set to generate a feed
90 |
91 | [documents]
92 | logo = "content/logo.html" # can contain emoji, , etc. see defaults/sample-logo.html in repo for instructions
93 | about = "content/about.md"
94 | rules = "content/rules.md"
95 | registration_instructions = "content/registration-instructions.md"
96 | ```
97 |
98 | Content documents that are not found will be prepopulated using Cerca's [sample content
99 | files](/defaults). The easiest thing to do is to run Cerca once and let it populate content
100 | files using the samples, and then edit the files in `content/*` after the fact, before running
101 | Cerca again to see your changes.
102 |
103 | Either write your own configuration following the above format, or run cerca once to populate it and
104 | then edit the created config.
105 |
106 | ## Contributing
107 | If you want to join the fun, first have a gander at the [CONTRIBUTING.md](/CONTRIBUTING.md)
108 | document. It lays out the overall idea of the project, and outlines what kind of contributions
109 | will help improve the project.
110 |
111 | ### Translations
112 |
113 | Cerca supports use with different natural languages. To translate Cerca into your language, please
114 | have a look at the existing [translations (i18n.go)](/i18n/i18n.go) and submit yours as a
115 | [pull request](https://github.com/cblgh/cerca/compare).
116 |
117 | ## Local development
118 |
119 | Install [golang](https://go.dev/).
120 |
121 | To launch a local instance of the forum, run those commands (linux):
122 |
123 | - `go run ./cmd/cerca --dev`
124 |
125 | It should respond `Serving forum on :8277`. Just go on [http://localhost:8277](http://localhost:8277).
126 |
127 | ### Building a binary
128 |
129 | ```
130 | go build ./cmd/cerca
131 | ```
132 |
133 | ### Building with reduced size
134 | This is optional, but if you want to minimize the size of the binary follow the instructions
135 | below. Less useful for active development, more useful for sending binaries to other computers.
136 |
137 | Pass `-ldflags="-s -w"` when building your binary:
138 |
139 | ```
140 | go build -ldflags="-s -w" ./cmd/cerca
141 | ```
142 |
143 | Additionally, run [upx](https://upx.github.io) on any generated binary:
144 |
145 | ```
146 | upx --lzma cerca
147 | ```
148 |
--------------------------------------------------------------------------------
/cmd/cerca/admin.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "cerca/constants"
5 | "cerca/database"
6 | "flag"
7 | "fmt"
8 | "os"
9 | )
10 |
11 | func admin() {
12 | var username string
13 | var forumDomain string
14 | var dbPath string
15 |
16 | adminFlags := flag.NewFlagSet("makeadmin", flag.ExitOnError)
17 | adminFlags.StringVar(&forumDomain, "url", "https://forum.merveilles.town", "root url to forum, referenced in output")
18 | adminFlags.StringVar(&username, "username", "", "username who should be made admin")
19 | adminFlags.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db")
20 |
21 | help := createHelpString("makeadmin", []string{
22 | `cerca makeadmin -username ""`,
23 | })
24 | adminFlags.Usage = func() { usage(help, adminFlags) }
25 | adminFlags.Parse(os.Args[2:])
26 |
27 | // if run without flags, print the help info
28 | if adminFlags.NFlag() == 0 {
29 | adminFlags.Usage()
30 | return
31 | }
32 |
33 | adminRoute := fmt.Sprintf("%s/admin", forumDomain)
34 |
35 | if username == "" {
36 | complain(help)
37 | }
38 |
39 | // check if database exists! we dont wanna create a new db in this case ':)
40 | if !database.CheckExists(dbPath) {
41 | complain("couldn't find database at %s", dbPath)
42 | }
43 |
44 | db := database.InitDB(dbPath)
45 |
46 | userid, err := db.GetUserID(username)
47 | if err != nil {
48 | complain("username %s not in database", username)
49 | }
50 | inform("Attempting to make %s (id %d) admin...", username, userid)
51 | err = db.AddAdmin(userid)
52 | if err != nil {
53 | complain("Something went wrong: %s", err)
54 | }
55 |
56 | // log cmd actions just as admin web-actions are logged
57 | systemUserid := db.GetSystemUserID()
58 | err = db.AddModerationLog(systemUserid, userid, constants.MODLOG_ADMIN_MAKE)
59 | if err != nil {
60 | complain("adding mod log for adding new admin failed (%w)", err)
61 | }
62 |
63 | inform("Successfully added %s (id %d) as an admin", username, userid)
64 | inform("Please visit %s for all your administration needs (changing usernames, resetting passwords, deleting user accounts)", adminRoute)
65 | inform("Admin action has been logged to /moderations")
66 | }
67 |
--------------------------------------------------------------------------------
/cmd/cerca/authkey.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "cerca/crypto"
5 | "crypto/sha256"
6 | "flag"
7 | )
8 |
9 | func authkey() {
10 | authkeyFlags := flag.NewFlagSet("authkey", flag.ExitOnError)
11 |
12 | help := createHelpString("authkey", []string{
13 | `cerca authkey `,
14 | })
15 | authkeyFlags.Usage = func() { usage(help, authkeyFlags) }
16 |
17 | hashInput := []byte(crypto.GeneratePassword())
18 | h := sha256.New()
19 | h.Write(hashInput)
20 | inform("Generated a random key:")
21 | inform("--authkey %x", h.Sum(nil))
22 | }
23 |
--------------------------------------------------------------------------------
/cmd/cerca/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 |
10 | "cerca/defaults"
11 | "cerca/server"
12 | "cerca/util"
13 | )
14 |
15 | var commandExplanations = map[string]string{
16 | "run": "run the forum",
17 | "adduser": "create a new user",
18 | "makeadmin": "make an existing user an admin",
19 | "migrate": "manage database migrations",
20 | "resetpw": "reset a user's password",
21 | "genauthkey": "generate and output an authkey for use with `cerca run`",
22 | "version": "output version information",
23 | }
24 |
25 | func createHelpString(commandName string, usageExamples []string) string {
26 | helpString := fmt.Sprintf("USAGE:\n %s\n\n %s\n",
27 | commandExplanations[commandName],
28 | strings.Join(usageExamples, "\n "))
29 |
30 | if commandName == "run" {
31 | helpString += "\nCOMMANDS:\n"
32 | cmds := []string{"adduser", "makeadmin", "migrate", "resetpw", "genauthkey", "version"}
33 | for _, key := range cmds {
34 | // pad first string with spaces to the right instead, set its expected width = 11
35 | helpString += fmt.Sprintf(" %-11s%s\n", key, commandExplanations[key])
36 | }
37 | }
38 |
39 | helpString += "\nOPTIONS:\n"
40 | return helpString
41 | }
42 |
43 | func usage(help string, fset *flag.FlagSet) {
44 | fmt.Fprintf(os.Stderr, help)
45 | if fset != nil {
46 | fset.PrintDefaults()
47 | return
48 | }
49 | flag.PrintDefaults()
50 | }
51 |
52 | func inform(msg string, args ...interface{}) {
53 | if len(args) > 0 {
54 | fmt.Printf("%s\n", fmt.Sprintf(msg, args...))
55 | } else {
56 | fmt.Printf("%s\n", msg)
57 | }
58 | }
59 |
60 | func complain(msg string, args ...interface{}) {
61 | if len(args) > 0 {
62 | inform(msg, args)
63 | } else {
64 | inform(msg)
65 | }
66 | os.Exit(0)
67 | }
68 |
69 | const DEFAULT_PORT = 8272
70 | const DEFAULT_DEV_PORT = 8277
71 |
72 | func run() {
73 | var sessionKey string
74 | var configPath string
75 | var dataDir string
76 | var dev bool
77 | var port int
78 |
79 | flag.BoolVar(&dev, "dev", false, "trigger development mode")
80 | flag.IntVar(&port, "port", DEFAULT_PORT, "port to run the forum on")
81 | flag.StringVar(&sessionKey, "authkey", "", "session cookies authentication key")
82 | flag.StringVar(&configPath, "config", "cerca.toml", "config and settings file containing cerca's customizations")
83 | flag.StringVar(&dataDir, "data", "./data", "directory where cerca will dump its database")
84 |
85 | help := createHelpString("run", []string{
86 | "cerca -authkey \"CHANGEME\"",
87 | "cerca -dev",
88 | })
89 | flag.Usage = func() { usage(help, nil) }
90 | flag.Parse()
91 |
92 | if flag.NFlag() == 0 {
93 | flag.Usage()
94 | return
95 | }
96 | // if dev mode and port not specified then use the default dev port to prevent collision with default serving port
97 | if dev && port == DEFAULT_PORT {
98 | port = DEFAULT_DEV_PORT
99 | }
100 |
101 | if len(sessionKey) == 0 {
102 | if !dev {
103 | complain("please pass a random session auth key with --authkey")
104 | }
105 | sessionKey = "0"
106 | }
107 |
108 | err := os.MkdirAll(dataDir, 0750)
109 | if err != nil {
110 | complain(fmt.Sprintf("couldn't create dir '%s'", dataDir))
111 | }
112 | config := util.ReadConfig(configPath)
113 | _, err = util.CreateIfNotExist(filepath.Join("html", "assets", "theme.css"), defaults.DEFAULT_THEME)
114 | if err != nil {
115 | complain("couldn't output default theme.css")
116 | }
117 | server.Serve(sessionKey, port, dev, dataDir, config)
118 | }
119 |
120 | func main() {
121 | command := "run"
122 | if len(os.Args) > 1 && (os.Args[1][0] != '-') {
123 | command = os.Args[1]
124 | }
125 |
126 | switch command {
127 | case "adduser":
128 | user()
129 | case "makeadmin":
130 | admin()
131 | case "migrate":
132 | migrate()
133 | case "resetpw":
134 | reset()
135 | case "run":
136 | run()
137 | case "genauthkey":
138 | authkey()
139 | case "version":
140 | version()
141 | default:
142 | fmt.Printf("ERR: no such subcommand '%s'\n", command)
143 | run()
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/cmd/cerca/migrate.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "cerca/database"
5 | "flag"
6 | "fmt"
7 | "os"
8 | )
9 |
10 | func migrate() {
11 | migrations := map[string]func(string) error{
12 | "2024-01-password-hash-migration": database.Migration20240116_PwhashChange,
13 | "2024-02-thread-private-migration": database.Migration20240720_ThreadPrivateChange,
14 | }
15 |
16 | var dbPath, migration string
17 | var listMigrations bool
18 |
19 | migrateFlags := flag.NewFlagSet("migrate", flag.ExitOnError)
20 | migrateFlags.BoolVar(&listMigrations, "list", false, "list possible migrations")
21 | migrateFlags.StringVar(&migration, "migration", "", "name of the migration you want to perform on the database")
22 | migrateFlags.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db")
23 |
24 | help := createHelpString("migrate", []string{
25 | `cerca migrate -migration 2024-02-thread-private-migration`,
26 | "cerca migrate -list",
27 | })
28 | migrateFlags.Usage = func() { usage(help, migrateFlags) }
29 | migrateFlags.Parse(os.Args[2:])
30 |
31 | // if run without flags, print the help info
32 | if migrateFlags.NFlag() == 0 {
33 | migrateFlags.Usage()
34 | return
35 | }
36 |
37 | if listMigrations {
38 | inform("Possible migrations:")
39 | for key := range migrations {
40 | fmt.Println("\t", key)
41 | }
42 | os.Exit(0)
43 | }
44 |
45 | if migration == "" {
46 | complain(help)
47 | } else if _, ok := migrations[migration]; !ok {
48 | complain(fmt.Sprintf("chosen migration »%s» does not match one of the available migrations. see migrations with flag --list", migration))
49 | }
50 |
51 | // check if database exists! we dont wanna create a new db in this case ':)
52 | if !database.CheckExists(dbPath) {
53 | complain("couldn't find database at %s", dbPath)
54 | }
55 |
56 | // perform migration
57 | err := migrations[migration](dbPath)
58 | if err == nil {
59 | inform(fmt.Sprintf("Migration »%s» completed", migration))
60 | } else {
61 | complain("migration terminated early due to error")
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/cmd/cerca/reset.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "cerca/constants"
5 | "cerca/database"
6 | "flag"
7 | "os"
8 | )
9 |
10 | func reset() {
11 | var username string
12 | var dbPath string
13 |
14 | resetFlags := flag.NewFlagSet("resetpw", flag.ExitOnError)
15 | resetFlags.StringVar(&username, "username", "", "username whose credentials should be reset")
16 | resetFlags.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db")
17 |
18 | help := createHelpString("resetpw", []string{
19 | `cerca resetpw -username ""`,
20 | })
21 | resetFlags.Usage = func() { usage(help, resetFlags) }
22 | resetFlags.Parse(os.Args[2:])
23 |
24 | // if run without flags, print the help info
25 | if resetFlags.NFlag() == 0 {
26 | resetFlags.Usage()
27 | return
28 | }
29 |
30 | if username == "" {
31 | complain(help)
32 | }
33 |
34 | // check if database exists! we dont wanna create a new db in this case ':)
35 | if !database.CheckExists(dbPath) {
36 | complain("couldn't find database at %s", dbPath)
37 | }
38 |
39 | db := database.InitDB(dbPath)
40 |
41 | userid, err := db.GetUserID(username)
42 | if err != nil {
43 | complain("reset password failed (%w)", err)
44 | }
45 | newPassword, err := db.ResetPassword(userid)
46 |
47 | if err != nil {
48 | complain("reset password failed (%w)", err)
49 | }
50 |
51 | // log cmd actions just as admin web-actions are logged
52 | systemUserid := db.GetSystemUserID()
53 | err = db.AddModerationLog(systemUserid, userid, constants.MODLOG_RESETPW)
54 | if err != nil {
55 | complain("adding mod log for password reset failed (%w)", err)
56 | }
57 |
58 | inform("Successfully updated %s's password hash", username)
59 | inform("New temporary password: %s", newPassword)
60 | inform("Admin action has been logged to /moderations")
61 | }
62 |
--------------------------------------------------------------------------------
/cmd/cerca/user.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "cerca/constants"
5 | "cerca/crypto"
6 | "cerca/database"
7 | "cerca/util"
8 | "flag"
9 | "fmt"
10 | "os"
11 | )
12 |
13 | type UserInfo struct {
14 | ID int
15 | Username, Password string
16 | }
17 |
18 | func createUser(username, password string, db *database.DB) UserInfo {
19 | ed := util.Describe("admin reset")
20 | // make sure username is not registered already
21 | var err error
22 | if exists, err := db.CheckUsernameExists(username); err != nil {
23 | complain("Database had a problem when checking username")
24 | } else if exists {
25 | complain("Username %s appears to already exist, please pick another name", username)
26 | }
27 | var hash string
28 | if hash, err = crypto.HashPassword(password); err != nil {
29 | complain("Database had a problem when hashing password")
30 | }
31 | var userID int
32 | if userID, err = db.CreateUser(username, hash); err != nil {
33 | complain("Error in db when creating user")
34 | }
35 | // log where the registration is coming from, in the case of indirect invites && for curiosity
36 | err = db.AddRegistration(userID, "https://example.com/admin-add-user")
37 | if err = ed.Eout(err, "add registration"); err != nil {
38 | complain("Database had a problem saving user registration location")
39 | }
40 | return UserInfo{ID: userID, Username: username, Password: password}
41 | }
42 |
43 | func user() {
44 | var username string
45 | var forumDomain string
46 | var dbPath string
47 |
48 | userFlags := flag.NewFlagSet("adduser", flag.ExitOnError)
49 | userFlags.StringVar(&forumDomain, "url", "https://forum.merveilles.town", "root url to forum, referenced in output")
50 | userFlags.StringVar(&username, "username", "", "username who should be created")
51 | userFlags.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db")
52 |
53 | help := createHelpString("adduser", []string{
54 | `cerca adduser -username ""`,
55 | })
56 | userFlags.Usage = func() { usage(help, userFlags) }
57 | userFlags.Parse(os.Args[2:])
58 |
59 | // if run without flags, print the help info
60 | if userFlags.NFlag() == 0 {
61 | userFlags.Usage()
62 | return
63 | }
64 |
65 | if username == "" {
66 | complain(help)
67 | }
68 |
69 | // check if database exists! we dont wanna create a new db in this case ':)
70 | if !database.CheckExists(dbPath) {
71 | complain("couldn't find database at %s", dbPath)
72 | }
73 |
74 | db := database.InitDB(dbPath)
75 |
76 | newPassword := crypto.GeneratePassword()
77 | userInfo := createUser(username, newPassword, &db)
78 |
79 | // log cmd actions just as admin web-actions are logged
80 | systemUserid := db.GetSystemUserID()
81 | err := db.AddModerationLog(systemUserid, userInfo.ID, constants.MODLOG_ADMIN_ADD_USER)
82 | if err != nil {
83 | complain("adding mod log for adding new user failed (%w)", err)
84 | }
85 |
86 | loginRoute := fmt.Sprintf("%s/login", forumDomain)
87 | resetRoute := fmt.Sprintf("%s/reset", forumDomain)
88 |
89 | inform("[user]\n%s", username)
90 | inform("[password]\n%s", newPassword)
91 | inform("Please login at %s\n", loginRoute)
92 | inform("After logging in, visit %s to reset your password", resetRoute)
93 | inform("Admin action has been logged to /moderations")
94 | }
95 |
--------------------------------------------------------------------------------
/cmd/cerca/version.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "time"
5 | "runtime/debug"
6 | "fmt"
7 | )
8 |
9 | type CommitInfo struct {
10 | Date string
11 | Hash string
12 | }
13 |
14 | func (c CommitInfo) String() string {
15 | return fmt.Sprintf("latest commit %s - %s", c.Date, c.Hash)
16 | }
17 |
18 | func version() {
19 | now := time.Now()
20 | // we can only get the git commit info when built by invoking `go build` (as opposed to being built & immediately
21 | // executed via `go run`)
22 | isBuiltBinary := false
23 | var commit CommitInfo
24 | var golangVersion string
25 | date := now.UTC().Format(time.UnixDate)
26 | if info, ok := debug.ReadBuildInfo(); ok {
27 | golangVersion = info.GoVersion
28 | for _, setting := range info.Settings {
29 | if setting.Key == "vcs" {
30 | isBuiltBinary = true
31 | }
32 | if setting.Key == "vcs.revision" {
33 | commit.Hash = setting.Value
34 | }
35 | if setting.Key == "vcs.time" {
36 | t, err := time.Parse(time.RFC3339, setting.Value)
37 | if err == nil {
38 | commit.Date = t.Format(time.DateOnly)
39 | }
40 | }
41 | }
42 | }
43 | inform("built on %s with %s", date, golangVersion)
44 | if isBuiltBinary {
45 | inform(commit.String())
46 | } else {
47 | inform("running with go run, git hash not available")
48 | }
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/constants/constants.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | import "time"
4 |
5 | const (
6 | MODLOG_RESETPW = iota
7 | MODLOG_ADMIN_VETO // vetoing a proposal
8 | MODLOG_ADMIN_MAKE // make an admin
9 | MODLOG_REMOVE_USER // remove a user
10 | MODLOG_ADMIN_ADD_USER // add a new user
11 | MODLOG_ADMIN_DEMOTE // demote an admin back to a normal user
12 | MODLOG_ADMIN_CONFIRM // confirming a proposal
13 | MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN
14 | MODLOG_ADMIN_PROPOSE_MAKE_ADMIN
15 | MODLOG_ADMIN_PROPOSE_REMOVE_USER
16 | MODLOG_CREATE_INVITE_BATCH
17 | MODLOG_DELETE_INVITE_BATCH
18 | /* NOTE: when adding new values, only add them after already existing values! otherwise the existing variables will
19 | * receive new values which affects the stored values in table moderation_log */
20 | )
21 |
22 | const PROPOSAL_VETO = false
23 | const PROPOSAL_CONFIRM = true
24 | const PROPOSAL_SELF_CONFIRMATION_WAIT = time.Hour * 24 * 7 /* 1 week */
25 |
--------------------------------------------------------------------------------
/crypto/crypto.go:
--------------------------------------------------------------------------------
1 | package crypto
2 |
3 | import (
4 | "cerca/util"
5 | "crypto/rand"
6 | "github.com/matthewhartstonge/argon2"
7 | "math/big"
8 | "strings"
9 | )
10 |
11 | func HashPassword(s string) (string, error) {
12 | ed := util.Describe("hash password")
13 | config := argon2.MemoryConstrainedDefaults()
14 | hash, err := config.HashEncoded([]byte(s))
15 | if err != nil {
16 | return "", ed.Eout(err, "hashing with argon2id")
17 | }
18 | return string(hash), nil
19 | }
20 |
21 | func ValidatePasswordHash(password, passwordHash string) bool {
22 | ed := util.Describe("validate password hash")
23 | hashStruct, err := argon2.Decode([]byte(passwordHash))
24 | ed.Check(err, "argon2.decode")
25 | correct, err := hashStruct.Verify([]byte(password))
26 | if err != nil {
27 | return false
28 | }
29 | return correct
30 | }
31 |
32 | // used for generating a random reset password
33 | const characterSet = "abcdedfghijklmnopqrstABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
34 | const pwlength = 20
35 |
36 | func GeneratePassword() string {
37 | var password strings.Builder
38 | const maxChar = int64(len(characterSet))
39 |
40 | for i := 0; i < pwlength; i++ {
41 | max := big.NewInt(maxChar)
42 | bigN, err := rand.Int(rand.Reader, max)
43 | util.Check(err, "randomly generate int")
44 | n := bigN.Int64()
45 | password.WriteString(string(characterSet[n]))
46 | }
47 | return password.String()
48 | }
49 |
--------------------------------------------------------------------------------
/database/database.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "cerca/crypto"
5 | "context"
6 | "database/sql"
7 | "errors"
8 | "fmt"
9 | "log"
10 | "os"
11 | "regexp"
12 | "time"
13 |
14 | "cerca/util"
15 |
16 | _ "github.com/mattn/go-sqlite3"
17 | )
18 |
19 | type DB struct {
20 | db *sql.DB
21 | }
22 |
23 | func CheckExists(filepath string) bool {
24 | if _, err := os.Stat(filepath); errors.Is(err, os.ErrNotExist) {
25 | return false
26 | }
27 | return true
28 | }
29 |
30 | func InitDB(filepath string) DB {
31 | exists := CheckExists(filepath)
32 | if !exists {
33 | file, err := os.Create(filepath)
34 | if err != nil {
35 | log.Fatal(err)
36 | }
37 | defer file.Close()
38 | }
39 |
40 | db, err := sql.Open("sqlite3", filepath)
41 | util.Check(err, "opening sqlite3 database at %s", filepath)
42 | if db == nil {
43 | log.Fatalln("db is nil")
44 | }
45 | createTables(db)
46 | instance := DB{db}
47 | instance.makeSureDefaultUsersExist()
48 | return instance
49 | }
50 |
51 | const DELETED_USER_NAME = "deleted user"
52 | const SYSTEM_USER_NAME = "CERCA_CMD"
53 |
54 | func (d DB) makeSureDefaultUsersExist() {
55 | ed := util.Describe("create default users")
56 | for _, defaultUser := range []string{DELETED_USER_NAME, SYSTEM_USER_NAME} {
57 | userExists, err := d.CheckUsernameExists(defaultUser)
58 | if err != nil {
59 | log.Fatalln(ed.Eout(err, "check username for %s exists", defaultUser))
60 | }
61 | if !userExists {
62 | passwordHash, err := crypto.HashPassword(crypto.GeneratePassword())
63 | _, err = d.CreateUser(defaultUser, passwordHash)
64 | if err != nil {
65 | log.Fatalln(ed.Eout(err, "create %s", defaultUser))
66 | }
67 | }
68 | }
69 | }
70 |
71 | func createTables(db *sql.DB) {
72 | // create the table if it doesn't exist
73 | queries := []string{
74 | /* used for versioning migrations */
75 | `
76 | CREATE TABLE IF NOT EXISTS meta (
77 | schemaversion INTEGER NOT NULL
78 | );
79 | `,
80 | `
81 | CREATE TABLE IF NOT EXISTS users (
82 | id INTEGER PRIMARY KEY AUTOINCREMENT,
83 | name TEXT NOT NULL UNIQUE,
84 | passwordhash TEXT NOT NULL
85 | );
86 | `,
87 | `
88 | CREATE TABLE IF NOT EXISTS admins(
89 | id INTEGER PRIMARY KEY
90 | );
91 | `,
92 | /* add optional columns: quorumuser quorum_action (confirm, veto)? */
93 | `
94 | CREATE TABLE IF NOT EXISTS moderation_log (
95 | id INTEGER PRIMARY KEY AUTOINCREMENT,
96 | actingid INTEGER NOT NULL,
97 | recipientid INTEGER,
98 | action INTEGER NOT NULL,
99 | time DATE NOT NULL,
100 |
101 | FOREIGN KEY (actingid) REFERENCES users(id),
102 | FOREIGN KEY (recipientid) REFERENCES users(id)
103 | );
104 | `,
105 | `
106 | CREATE TABLE IF NOT EXISTS quorum_decisions (
107 | userid INTEGER NOT NULL,
108 | decision BOOL NOT NULL,
109 | modlogid INTEGER NOT NULL,
110 |
111 | FOREIGN KEY (modlogid) REFERENCES moderation_log(id)
112 | );
113 | `,
114 | `
115 | CREATE TABLE IF NOT EXISTS moderation_proposals (
116 | id INTEGER PRIMARY KEY AUTOINCREMENT,
117 | proposerid INTEGER NOT NULL,
118 | recipientid INTEGER NOT NULL,
119 | action INTEGER NOT NULL,
120 | time DATE NOT NULL,
121 |
122 | FOREIGN KEY (proposerid) REFERENCES users(id),
123 | FOREIGN KEY (recipientid) REFERENCES users(id)
124 | );
125 | `,
126 | `
127 | CREATE TABLE IF NOT EXISTS invites (
128 | id INTEGER PRIMARY KEY AUTOINCREMENT,
129 | batchid TEXT NOT NULL, -- uuid v4
130 | invite TEXT NOT NULL,
131 | label TEXT,
132 | adminid INTEGER NOT NULL,
133 | time DATE NOT NULL,
134 | reusable BOOL NOT NULL,
135 |
136 | FOREIGN KEY(adminid) REFERENCES users(id)
137 | );
138 | `,
139 | `
140 | CREATE TABLE IF NOT EXISTS registrations (
141 | id INTEGER PRIMARY KEY AUTOINCREMENT,
142 | userid INTEGER,
143 | host STRING,
144 | link STRING,
145 | time DATE,
146 | FOREIGN KEY (userid) REFERENCES users(id)
147 | );
148 | `,
149 |
150 | /* also known as forum categories; buckets of threads */
151 | `
152 | CREATE TABLE IF NOT EXISTS topics (
153 | id INTEGER PRIMARY KEY AUTOINCREMENT,
154 | name TEXT NOT NULL UNIQUE,
155 | description TEXT
156 | );
157 | `,
158 | /* thread link structure: ./thread//[] */
159 | `
160 | CREATE TABLE IF NOT EXISTS threads (
161 | id INTEGER PRIMARY KEY AUTOINCREMENT,
162 | title TEXT NOT NULL,
163 | publishtime DATE,
164 | topicid INTEGER,
165 | authorid INTEGER,
166 | private INTEGER NOT NULL DEFAULT 0,
167 | FOREIGN KEY(topicid) REFERENCES topics(id),
168 | FOREIGN KEY(authorid) REFERENCES users(id)
169 | );
170 | `,
171 | `
172 | CREATE TABLE IF NOT EXISTS posts (
173 | id INTEGER PRIMARY KEY AUTOINCREMENT,
174 | content TEXT NOT NULL,
175 | publishtime DATE,
176 | lastedit DATE,
177 | authorid INTEGER,
178 | threadid INTEGER,
179 | FOREIGN KEY(authorid) REFERENCES users(id),
180 | FOREIGN KEY(threadid) REFERENCES threads(id)
181 | );
182 | `}
183 |
184 | for _, query := range queries {
185 | if _, err := db.Exec(query); err != nil {
186 | log.Fatalln(util.Eout(err, "creating database table %s", query))
187 | }
188 | }
189 | }
190 |
191 | /* goal for 2021-12-26
192 | * create thread
193 | * create post
194 | * get thread
195 | * + html render of begotten thread
196 | */
197 |
198 | /* goal for 2021-12-28
199 | * in browser: reply on a thread
200 | * in browser: create a new thread
201 | */
202 | func (d DB) Exec(stmt string, args ...interface{}) (sql.Result, error) {
203 | return d.db.Exec(stmt, args...)
204 | }
205 |
206 | func (d DB) CreateThread(title, content string, authorid, topicid int, isPrivate bool) (int, error) {
207 | ed := util.Describe("create thread")
208 | // create the new thread in a transaction spanning two statements
209 | tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options?
210 | ed.Check(err, "start transaction")
211 | // first, create the new thread
212 | publish := time.Now()
213 | threadStmt := `INSERT INTO threads (title, publishtime, topicid, authorid, private) VALUES (?, ?, ?, ?, ?)
214 | RETURNING id`
215 | replyStmt := `INSERT INTO posts (content, publishtime, threadid, authorid) VALUES (?, ?, ?, ?)`
216 | var threadid int
217 | private := 0
218 | if isPrivate {
219 | private = 1
220 | }
221 | err = tx.QueryRow(threadStmt, title, publish, topicid, authorid, private).Scan(&threadid)
222 | if err = ed.Eout(err, "add thread %s (private: %d) by %d in topic %d", title, private, authorid, topicid); err != nil {
223 | _ = tx.Rollback()
224 | log.Println(err, "rolling back")
225 | return -1, err
226 | }
227 | // then add the content as the first reply to the thread
228 | _, err = tx.Exec(replyStmt, content, publish, threadid, authorid)
229 | if err = ed.Eout(err, "add initial reply for thread %d", threadid); err != nil {
230 | _ = tx.Rollback()
231 | log.Println(err, "rolling back")
232 | return -1, err
233 | }
234 | err = tx.Commit()
235 | ed.Check(err, "commit transaction")
236 | // finally return the id of the created thread, so we can do a friendly redirect
237 | return threadid, nil
238 | }
239 |
240 | // c.f.
241 | // https://medium.com/aubergine-solutions/how-i-handled-null-possible-values-from-database-rows-in-golang-521fb0ee267
242 | // type NullTime sql.NullTime
243 | type Post struct {
244 | ID int
245 | ThreadTitle string
246 | ThreadID int
247 | Content string // markdown
248 | Author string
249 | AuthorID int
250 | Publish time.Time
251 | LastEdit sql.NullTime // TODO: handle json marshalling with custom type
252 | }
253 |
254 | func (d DB) DeleteThread() {}
255 | func (d DB) MoveThread() {}
256 |
257 | // TODO(2021-12-28): return error if non-existent thread
258 | func (d DB) GetThread(threadid int) ([]Post, error) {
259 | // TODO: make edit work if no edit timestamp detected e.g.
260 | // (sql: Scan error on column index 3, name "lastedit": unsupported Scan, storing driver.Value type into type
261 | // *time.Time)
262 |
263 | exists, err := d.CheckThreadExists(threadid)
264 | if err != nil || !exists {
265 | return []Post{}, errors.New(fmt.Sprintf("GetThread: threadid %d did not exist", threadid))
266 | }
267 | // join with:
268 | // users table to get user name
269 | // threads table to get thread title
270 | query := `
271 | SELECT p.id, t.title, content, u.name, p.authorid, p.publishtime, p.lastedit
272 | FROM posts p
273 | INNER JOIN users u ON u.id = p.authorid
274 | INNER JOIN threads t ON t.id = p.threadid
275 | WHERE threadid = ?
276 | ORDER BY p.publishtime
277 | `
278 | stmt, err := d.db.Prepare(query)
279 | util.Check(err, "get thread: prepare query")
280 | defer stmt.Close()
281 |
282 | rows, err := stmt.Query(threadid)
283 | util.Check(err, "get thread: query")
284 | defer rows.Close()
285 |
286 | var data Post
287 | var posts []Post
288 | for rows.Next() {
289 | if err := rows.Scan(&data.ID, &data.ThreadTitle, &data.Content, &data.Author, &data.AuthorID, &data.Publish, &data.LastEdit); err != nil {
290 | log.Fatalln(util.Eout(err, "get data for thread %d", threadid))
291 | }
292 | posts = append(posts, data)
293 | }
294 | return posts, nil
295 | }
296 |
297 | func (d DB) GetPost(postid int) (Post, error) {
298 | stmt := `
299 | SELECT p.id, t.title, t.id, content, u.name, p.authorid, p.publishtime, p.lastedit
300 | FROM posts p
301 | INNER JOIN users u ON u.id = p.authorid
302 | INNER JOIN threads t ON t.id = p.threadid
303 | WHERE p.id = ?
304 | `
305 | var data Post
306 | err := d.db.QueryRow(stmt, postid).Scan(&data.ID, &data.ThreadTitle, &data.ThreadID, &data.Content, &data.Author, &data.AuthorID, &data.Publish, &data.LastEdit)
307 | err = util.Eout(err, "get data for thread %d", postid)
308 | return data, err
309 | }
310 |
311 | type Thread struct {
312 | Title string
313 | Author string
314 | Slug string
315 | Private bool
316 | ID int
317 | Show bool // whether to show the thread in the thread index or not
318 | Publish time.Time
319 | PostID int
320 | }
321 |
322 | var categoryPattern = regexp.MustCompile(`\[(.*?)\]`)
323 |
324 | func (t Thread) GetCategory() string {
325 | matches := categoryPattern.FindStringSubmatch(t.Title)
326 | if matches == nil {
327 | return "no category"
328 | }
329 | return matches[1]
330 | }
331 |
332 | // get a list of threads
333 | // NOTE: this query is setting thread.Author not by thread creator, but latest poster. if this becomes a problem, revert
334 | // its use and employ Thread.PostID to perform another query for each thread to get the post author name (wrt server.go:GenerateRSS)
335 | func (d DB) ListThreads(sortByPost bool, includePrivate bool) []Thread {
336 | query := `
337 | SELECT count(t.id), t.title, t.id, t.private, u.name, p.publishtime, p.id FROM threads t
338 | INNER JOIN users u on u.id = p.authorid
339 | INNER JOIN posts p ON t.id = p.threadid
340 | %s
341 | GROUP BY t.id
342 | %s
343 | `
344 | orderBy := `ORDER BY t.publishtime DESC`
345 | // get a list of threads by ordering them based on most recent post
346 | if sortByPost {
347 | orderBy = `ORDER BY max(p.id) DESC`
348 | }
349 | where := `WHERE t.private = 0`
350 | if includePrivate {
351 | where = `WHERE t.private IN (0,1)`
352 | }
353 | query = fmt.Sprintf(query, where, orderBy)
354 |
355 | stmt, err := d.db.Prepare(query)
356 | util.Check(err, "list threads: prepare query")
357 | defer stmt.Close()
358 |
359 | rows, err := stmt.Query()
360 | util.Check(err, "list threads: query")
361 | defer rows.Close()
362 |
363 | var postCount int
364 | var data Thread
365 | var isPrivate int
366 | var threads []Thread
367 | for rows.Next() {
368 | if err := rows.Scan(&postCount, &data.Title, &data.ID, &isPrivate, &data.Author, &data.Publish, &data.PostID); err != nil {
369 | log.Fatalln(util.Eout(err, "list threads: read in data via scan"))
370 | }
371 | data.Private = (isPrivate == 1)
372 | data.Slug = util.GetThreadSlug(data.ID, data.Title, postCount)
373 | threads = append(threads, data)
374 | }
375 | return threads
376 | }
377 |
378 | func (d DB) IsThreadPrivate(threadid int) (bool, error) {
379 | exists, err := d.CheckThreadExists(threadid)
380 |
381 | if err != nil || !exists {
382 | return true, errors.New(fmt.Sprintf("IsThreadPrivate: threadid %d did not exist", threadid))
383 | }
384 |
385 | var private int
386 | stmt := `SELECT private FROM threads where id = ?`
387 | err = d.db.QueryRow(stmt, threadid).Scan(&private)
388 | util.Check(err, "querying if private thread %d", threadid)
389 | return private == 1, nil
390 | }
391 |
392 | func (d DB) AddPost(content string, threadid, authorid int) (postID int) {
393 | stmt := `INSERT INTO posts (content, publishtime, threadid, authorid) VALUES (?, ?, ?, ?) RETURNING id`
394 | publish := time.Now()
395 | err := d.db.QueryRow(stmt, content, publish, threadid, authorid).Scan(&postID)
396 | util.Check(err, "add post to thread %d (author %d)", threadid, authorid)
397 | return
398 | }
399 |
400 | func (d DB) EditPost(content, title string, postid, threadid int) {
401 | stmt := `UPDATE posts set content = ?, lastedit = ? WHERE id = ?`
402 | edit := time.Now()
403 | _, err := d.Exec(stmt, content, edit, postid)
404 | util.Check(err, "edit post %d", postid)
405 |
406 | stmt = `UPDATE threads set title = ? WHERE id = ?`
407 | edit = time.Now()
408 | _, err = d.Exec(stmt, title, threadid)
409 | util.Check(err, "edit post title %d", postid)
410 | }
411 |
412 | func (d DB) DeletePost(postid int) error {
413 | stmt := `DELETE FROM posts WHERE id = ?`
414 | _, err := d.Exec(stmt, postid)
415 | return util.Eout(err, "deleting post %d", postid)
416 | }
417 |
418 | func (d DB) CreateTopic(title, description string) {
419 | stmt := `INSERT INTO topics (name, description) VALUES (?, ?)`
420 | _, err := d.Exec(stmt, title, description)
421 | util.Check(err, "creating topic %s", title)
422 | }
423 |
424 | func (d DB) UpdateTopicName(topicid int, newname string) {
425 | stmt := `UPDATE topics SET name = ? WHERE id = ?`
426 | _, err := d.Exec(stmt, newname, topicid)
427 | util.Check(err, "changing topic %d's name to %s", topicid, newname)
428 | }
429 |
430 | func (d DB) UpdateTopicDescription(topicid int, newdesc string) {
431 | stmt := `UPDATE topics SET description = ? WHERE id = ?`
432 | _, err := d.Exec(stmt, newdesc, topicid)
433 | util.Check(err, "changing topic %d's description to %s", topicid, newdesc)
434 | }
435 |
436 | func (d DB) DeleteTopic(topicid int) {
437 | stmt := `DELETE FROM topics WHERE id = ?`
438 | _, err := d.Exec(stmt, topicid)
439 | util.Check(err, "deleting topic %d", topicid)
440 | }
441 |
442 | func (d DB) CreateUser(name, hash string) (int, error) {
443 | stmt := `INSERT INTO users (name, passwordhash) VALUES (?, ?) RETURNING id`
444 | var userid int
445 | err := d.db.QueryRow(stmt, name, hash).Scan(&userid)
446 | if err != nil {
447 | return -1, util.Eout(err, "creating user %s", name)
448 | }
449 | return userid, nil
450 | }
451 |
452 | func (d DB) GetUserID(name string) (int, error) {
453 | stmt := `SELECT id FROM users where name = ?`
454 | var userid int
455 | err := d.db.QueryRow(stmt, name).Scan(&userid)
456 | if err != nil {
457 | return -1, util.Eout(err, "get user id")
458 | }
459 | return userid, nil
460 | }
461 |
462 | func (d DB) GetUsername(uid int) (string, error) {
463 | stmt := `SELECT name FROM users where id = ?`
464 | var username string
465 | err := d.db.QueryRow(stmt, uid).Scan(&username)
466 | if err != nil {
467 | return "", util.Eout(err, "get username")
468 | }
469 | return username, nil
470 | }
471 |
472 | func (d DB) GetPasswordHash(username string) (string, int, error) {
473 | stmt := `SELECT passwordhash, id FROM users where name = ?`
474 | var hash string
475 | var userid int
476 | err := d.db.QueryRow(stmt, username).Scan(&hash, &userid)
477 | if err != nil {
478 | return "", -1, util.Eout(err, "get password hash")
479 | }
480 | return hash, userid, nil
481 | }
482 |
483 | func (d DB) GetPasswordHashByUserID(userid int) (string, error) {
484 | stmt := `SELECT passwordhash FROM users where id = ?`
485 | var hash string
486 | err := d.db.QueryRow(stmt, userid).Scan(&hash)
487 | if err != nil {
488 | return "", util.Eout(err, "get password hash by userid")
489 | }
490 | return hash, nil
491 | }
492 |
493 | func (d DB) existsQuery(substmt string, args ...interface{}) (bool, error) {
494 | stmt := fmt.Sprintf(`SELECT exists (%s)`, substmt)
495 | var exists bool
496 | err := d.db.QueryRow(stmt, args...).Scan(&exists)
497 | if err != nil {
498 | return false, util.Eout(err, "exists: %s", substmt)
499 | }
500 | return exists, nil
501 | }
502 |
503 | func (d DB) CheckUserExists(userid int) (bool, error) {
504 | stmt := `SELECT 1 FROM users WHERE id = ?`
505 | return d.existsQuery(stmt, userid)
506 | }
507 |
508 | func (d DB) CheckUsernameExists(username string) (bool, error) {
509 | stmt := `SELECT 1 FROM users WHERE name = ?`
510 | return d.existsQuery(stmt, username)
511 | }
512 |
513 | func (d DB) CheckThreadExists(threadid int) (bool, error) {
514 | stmt := `SELECT 1 FROM threads WHERE id = ?`
515 | return d.existsQuery(stmt, threadid)
516 | }
517 |
518 | func (d DB) UpdateUsername(userid int, newname string) {
519 | stmt := `UPDATE users SET name = ? WHERE id = ?`
520 | _, err := d.Exec(stmt, newname, userid)
521 | util.Check(err, "changing user %d's name to %s", userid, newname)
522 | }
523 |
524 | func (d DB) UpdateUserPasswordHash(userid int, newhash string) {
525 | stmt := `UPDATE users SET passwordhash = ? WHERE id = ?`
526 | _, err := d.Exec(stmt, newhash, userid)
527 | util.Check(err, "changing user %d's description to %s", userid, newhash)
528 | }
529 |
530 | func (d DB) GetSystemUserID() int {
531 | ed := util.Describe("get system user id")
532 | systemUserid, err := d.GetUserID(SYSTEM_USER_NAME)
533 | // it should always exist
534 | if err != nil {
535 | log.Fatalln(ed.Eout(err, "get system user id"))
536 | }
537 | return systemUserid
538 | }
539 |
540 | func (d DB) AddRegistration(userid int, registrationOrigin string) error {
541 | ed := util.Describe("add registration")
542 | stmt := `INSERT INTO registrations (userid, link, time) VALUES (?, ?, ?)`
543 | t := time.Now()
544 | _, err := d.Exec(stmt, userid, registrationOrigin, t)
545 | if err = ed.Eout(err, "add registration"); err != nil {
546 | return err
547 | }
548 | return nil
549 | }
550 |
551 | /* for moderation operations and queries, see database/moderation.go */
552 |
553 | func (d DB) GetUsers(includeAdmin bool) []User {
554 | ed := util.Describe("get users")
555 | query := `SELECT u.name, u.id, r.link
556 | FROM users u LEFT OUTER JOIN registrations r on u.id = r.userid
557 | %s
558 | ORDER BY u.id
559 | `
560 |
561 | if includeAdmin {
562 | query = fmt.Sprintf(query, "") // do nothing
563 | } else {
564 | query = fmt.Sprintf(query, "WHERE u.id NOT IN (select id from admins)") // do nothing
565 | }
566 |
567 | stmt, err := d.db.Prepare(query)
568 | ed.Check(err, "prep stmt")
569 | defer stmt.Close()
570 |
571 | rows, err := stmt.Query()
572 | util.Check(err, "run query")
573 | defer rows.Close()
574 |
575 | var user User
576 | var users []User
577 | var registrationOrigin sql.NullString
578 | for rows.Next() {
579 | if err := rows.Scan(&user.Name, &user.ID, ®istrationOrigin); err != nil {
580 | ed.Check(err, "scanning loop")
581 | }
582 | if registrationOrigin.Valid {
583 | user.RegistrationOrigin = registrationOrigin.String
584 | } else {
585 | user.RegistrationOrigin = "no registered info"
586 | }
587 | users = append(users, user)
588 | }
589 | return users
590 | }
591 |
592 | func (d DB) ResetPassword(userid int) (string, error) {
593 | ed := util.Describe("reset password")
594 | exists, err := d.CheckUserExists(userid)
595 | if !exists {
596 | return "", errors.New(fmt.Sprintf("reset password: userid %d did not exist", userid))
597 | } else if err != nil {
598 | return "", fmt.Errorf("reset password encountered an error (%w)", err)
599 | }
600 | // generate new password for user and set it in the database
601 | newPassword := crypto.GeneratePassword()
602 | passwordHash, err := crypto.HashPassword(newPassword)
603 | if err != nil {
604 | return "", ed.Eout(err, "hash password")
605 | }
606 | d.UpdateUserPasswordHash(userid, passwordHash)
607 | return newPassword, nil
608 | }
609 |
--------------------------------------------------------------------------------
/database/migrations.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "cerca/util"
5 | "context"
6 | "database/sql"
7 | "encoding/base64"
8 | "errors"
9 | "fmt"
10 | "github.com/matthewhartstonge/argon2"
11 | "log"
12 | "regexp"
13 | "strconv"
14 | )
15 |
16 | /* switched argon2 library to support 32 bit due to flaw in previous library.
17 | * change occurred in commits:
18 | 68a689612547ff83225f9a2727cf0c14dfbf7ceb
19 | 27c6d5684b6b464b900889c4b8a4dbae232d6b68
20 |
21 | migration of the password hashes from synacor's embedded salt format to
22 | matthewartstonge's key-val embedded format
23 |
24 | migration details:
25 |
26 | the old format had the following default parameters:
27 | * time = 1
28 | * memory = 64MiB
29 | * threads = 4
30 | * keyLen = 32
31 | * saltLen = 16 bytes
32 | * hashLen = 32 bytes?
33 | * argonVersion = 13?
34 |
35 |
36 | the new format uses the following parameters:
37 | * TimeCost: 3,
38 | * MemoryCost: 64 * 1024, // 2^(16) (64MiB of RAM)
39 | * Parallelism: 4,
40 | * SaltLength: 16, // 16 * 8 = 128-bits
41 | * HashLength: 32, // 32 * 8 = 256-bits
42 | * Mode: ModeArgon2id,
43 | * Version: Version13,
44 |
45 | the diff:
46 | * time was changed to 3 from 1
47 | * the version may or may not be the same (0x13)
48 |
49 | a regex for changing the values would be the following
50 | old format example value:
51 | $argon2id19$1,65536,4$111111111111111111111111111111111111111111111111111111111111111111
52 | old format was also encoding the salt and hash, not in base64 but in a slightly custom format (see var `encoding`)
53 |
54 | regex to grab values
55 | \$argon2id19\$1,65536,4\$(\S{66})
56 | diff regex from old to new
57 | $argon2id$v=19$m=65536,t=${1},p=4${passwordhash}
58 | new format example value:
59 | $argon2id$v=19$m=65536,t=3,p=4$222222222222222222222222222222222222222222222222222222222222222222
60 | */
61 |
62 | func Migration20240116_PwhashChange(filepath string) (finalErr error) {
63 | d := InitDB(filepath)
64 | ed := util.Describe("pwhash migration")
65 |
66 | // the encoding defined in the old hashing library for string representations
67 | // https://github.com/synacor/argon2id/blob/18569dfc600ba1ba89278c3c4789ad81dcab5bfb/argon2id.go#L48
68 | var encoding = base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding)
69 |
70 | // indices of the capture groups, index 0 is the matched string
71 | const (
72 | _ = iota
73 | TIME_INDEX
74 | SALT_INDEX
75 | HASH_INDEX
76 | )
77 |
78 | // regex to parse out:
79 | // 1. time parameter
80 | // 2. salt
81 | // 3. hash
82 | const oldArgonPattern = `^\$argon2id19\$(\d),65536,4\$(\S+)\$(\S+)$`
83 | oldRegex, err := regexp.Compile(oldArgonPattern)
84 | ed.Check(err, "failed to compile old argon encoding pattern")
85 | // regex to confirm new records
86 | const newArgonPattern = `^\$argon2id\$v=19\$m=65536,t=(\d),p=4\$(\S+)\$(\S+)$`
87 | newRegex, err := regexp.Compile(newArgonPattern)
88 | ed.Check(err, "failed to compile new argon encoding pattern")
89 |
90 | // always perform migrations in a single transaction
91 | tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{})
92 | rollbackOnErr := func(incomingErr error) bool {
93 | if incomingErr != nil {
94 | _ = tx.Rollback()
95 | log.Println(incomingErr, "\nrolling back")
96 | finalErr = incomingErr
97 | return true
98 | }
99 | return false
100 | }
101 |
102 | // check table meta's schemaversion to see that it's empty (because i didn't set it initially X)
103 | row := tx.QueryRow(`SELECT schemaversion FROM meta`)
104 | placeholder := -1
105 | err = row.Scan(&placeholder)
106 | // we *want* to have no rows
107 | if err != nil && !errors.Is(err, sql.ErrNoRows) {
108 | if rollbackOnErr(err) {
109 | return
110 | }
111 | }
112 | // in this migration, we should *not* have any schemaversion set
113 | if placeholder > 0 {
114 | if rollbackOnErr(errors.New("schemaversion existed! there's a high likelihood that this migration has already been performed - exiting")) {
115 | return
116 | }
117 | }
118 |
119 | // alright onwards to the beesknees
120 | // data struct to keep passwords and ids together - dont wanna mix things up now do we
121 | type HashRecord struct {
122 | id int
123 | oldFormat string // the full encoded format of prev library. including salt and parameters, not just the hash
124 | newFormat string // the full encoded format of new library. including salt and parameters, not just the hash
125 | valid bool // only valid records will be updated (i.e. records whose format is confirmed by the the oldPattern regex)
126 | }
127 |
128 | var records []HashRecord
129 | // get all password hashes and the id of their row
130 | query := `SELECT id, passwordhash FROM users`
131 | rows, err := tx.Query(query)
132 | if rollbackOnErr(err) {
133 | return
134 | }
135 |
136 | for rows.Next() {
137 | var record HashRecord
138 | err = rows.Scan(&record.id, &record.oldFormat)
139 | if rollbackOnErr(err) {
140 | return
141 | }
142 | if record.id == 0 {
143 | if rollbackOnErr(errors.New("record id was not changed during scanning")) {
144 | return
145 | }
146 | }
147 | records = append(records, record)
148 | }
149 |
150 | // make the requisite pattern changes to the password hash
151 | config := argon2.MemoryConstrainedDefaults()
152 | for i := range records {
153 | // parse out the time, salt, and hash from the old record format
154 | matches := oldRegex.FindAllStringSubmatch(records[i].oldFormat, -1)
155 | if len(matches) > 0 {
156 | time, err := strconv.Atoi(matches[0][TIME_INDEX])
157 | rollbackOnErr(err)
158 | salt := matches[0][SALT_INDEX]
159 | hash := matches[0][HASH_INDEX]
160 |
161 | // decode the old format's had a custom encoding t
162 | // the correctly access the underlying buffers
163 | saltBuf, err := encoding.DecodeString(salt)
164 | util.Check(err, "decode salt using old format encoding")
165 | hashBuf, err := encoding.DecodeString(hash)
166 | util.Check(err, "decode hash using old format encoding")
167 |
168 | config.TimeCost = uint32(time) // note this change, to match old time cost (necessary!)
169 | raw := argon2.Raw{Config: config, Salt: saltBuf, Hash: hashBuf}
170 | // this is what we will store in the database instead
171 | newFormatEncoded := raw.Encode()
172 | ok := newRegex.Match(newFormatEncoded)
173 | if !ok {
174 | if rollbackOnErr(errors.New("newly formed format doesn't match regex for new pattern")) {
175 | return
176 | }
177 | }
178 | records[i].newFormat = string(newFormatEncoded)
179 | records[i].valid = true
180 | } else {
181 | // parsing the old format failed, let's check to see if this happens to be a new record
182 | // (if it is, we'll just ignore it. but if it's not we error out of here)
183 | ok := newRegex.MatchString(records[i].oldFormat)
184 | if !ok {
185 | // can't parse with regex matching old format or the new format
186 | if rollbackOnErr(errors.New(fmt.Sprintf("unknown record format: %s", records[i].oldFormat))) {
187 | return
188 | }
189 | }
190 | }
191 | }
192 |
193 | fmt.Println(records)
194 |
195 | fmt.Println("parsed and re-encoded all valid records from the old to the new format. proceeding to update database records")
196 | for _, record := range records {
197 | if !record.valid {
198 | continue
199 | }
200 | // update each row with the password hash in the new format
201 | stmt, err := tx.Prepare("UPDATE users SET passwordhash = ? WHERE id = ?")
202 | defer stmt.Close()
203 | _, err = stmt.Exec(record.newFormat, record.id)
204 | if rollbackOnErr(err) {
205 | return
206 | }
207 | }
208 | fmt.Println("all records were updated without any error")
209 | // when everything is done and dudsted insert schemaversion and set its value to 1
210 | // _, err = tx.Exec(`INSERT INTO meta (schemaversion) VALUES (1)`)
211 | // if rollbackOnErr(err) {
212 | // return
213 | // }
214 | _ = tx.Commit()
215 | return
216 | }
217 |
218 | func Migration20240720_ThreadPrivateChange(filepath string) (finalErr error) {
219 | d := InitDB(filepath)
220 |
221 | // always perform migrations in a single transaction
222 | tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{})
223 | rollbackOnErr := func(incomingErr error) bool {
224 | if incomingErr != nil {
225 | _ = tx.Rollback()
226 | log.Println(incomingErr, "\nrolling back")
227 | finalErr = incomingErr
228 | return true
229 | }
230 | return false
231 | }
232 |
233 | stmt := `ALTER TABLE threads
234 | ADD COLUMN private INTEGER NOT NULL DEFAULT 0
235 | `
236 |
237 | _, err = tx.Exec(stmt)
238 | if err != nil {
239 | if rollbackOnErr(err) {
240 | return
241 | }
242 | }
243 |
244 | _ = tx.Commit()
245 |
246 | return nil
247 | }
248 |
--------------------------------------------------------------------------------
/database/moderation.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "errors"
7 | "fmt"
8 | "log"
9 | "sort"
10 | "strconv"
11 | "time"
12 |
13 | "cerca/constants"
14 | "cerca/crypto"
15 | "cerca/util"
16 |
17 | _ "github.com/mattn/go-sqlite3"
18 | )
19 |
20 | // there are a bunch of places that reference a user's id, so i don't want to break all of those
21 | //
22 | // i also want to avoid big invisible holes in a conversation's history
23 | //
24 | // remove user performs the following operation:
25 | // 1. checks to see if the DELETED USER exists; otherwise create it and remember its id
26 | //
27 | // 2. if it exists, we swap out the userid for the DELETED_USER in tables:
28 | // - table threads authorid
29 | // - table posts authorid
30 | // - table moderation_log actingid or recipientid
31 | //
32 | // the entry in registrations correlating to userid is removed
33 | // if allowing deletion of post contents as well when removing account,
34 | // userid should be used to get all posts from table posts and change the contents
35 | // to say _deleted_
36 | type RemoveUserOptions struct {
37 | KeepContent bool
38 | KeepUsername bool
39 | }
40 |
41 | func (d DB) RemoveUser(userid int, options RemoveUserOptions) (finalErr error) {
42 | keepContent := options.KeepContent
43 | keepUsername := options.KeepUsername
44 | ed := util.Describe("remove user")
45 | // there is a single user we call the "deleted user", and we make sure this deleted user exists on startup
46 | // they will take the place of the old user when they remove their account.
47 | deletedUserID, err := d.GetUserID(DELETED_USER_NAME)
48 | if err != nil {
49 | log.Fatalln(ed.Eout(err, "get deleted user id"))
50 | }
51 | // create a transaction spanning all our removal-related ops
52 | tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options?
53 | rollbackOnErr := func(incomingErr error) bool {
54 | if incomingErr != nil {
55 | _ = tx.Rollback()
56 | log.Println(incomingErr, "rolling back")
57 | finalErr = incomingErr
58 | return true
59 | }
60 | return false
61 | }
62 |
63 | if rollbackOnErr(ed.Eout(err, "start transaction")) {
64 | return
65 | }
66 |
67 | type Triplet struct {
68 | Desc string
69 | Statement string
70 | Args []any
71 | }
72 |
73 | // create prepared statements performing the required removal operations for tables that reference a userid as a
74 | // foreign key: threads, posts, moderation_log, and registrations
75 |
76 | rawTriples := []Triplet{}
77 | /* UPDATING THREADS */
78 | // if we remove the username we shall also have to alter the threads started by this user
79 | if !keepUsername {
80 | rawTriples = append(rawTriples, Triplet{"threads stmt", "UPDATE threads SET authorid = ? WHERE authorid = ?", []any{deletedUserID, userid}})
81 | }
82 |
83 | /* UPDATING POSTS */
84 | // now for interacting with authored posts, we shall have to handle all permutations of keeping/removing post contents and/or username attribution
85 | if !keepContent && !keepUsername {
86 | rawTriples = append(rawTriples, Triplet{"posts stmt", `UPDATE posts SET content = "_deleted_", authorid = ? WHERE authorid = ?`, []any{deletedUserID, userid}})
87 | } else if keepContent && !keepUsername {
88 | rawTriples = append(rawTriples, Triplet{"posts stmt", `UPDATE posts SET authorid = ? WHERE authorid = ?`, []any{deletedUserID, userid}})
89 | } else if !keepContent && keepUsername {
90 | rawTriples = append(rawTriples, Triplet{"posts stmt", `UPDATE posts SET content = "_deleted_" WHERE authorid = ?`, []any{userid}})
91 | }
92 |
93 | // TODO (2025-04-13): not sure whether altering modlog history like this is a good idea or not; accountability goes outta the window cause all you can see is " removed "
94 | /* UPDATING MODLOGS */
95 | if !keepUsername {
96 | rawTriples = append(rawTriples, Triplet{"modlog stmt#1", "UPDATE moderation_log SET recipientid = ? WHERE recipientid = ?", []any{deletedUserID, userid}})
97 | rawTriples = append(rawTriples, Triplet{"modlog stmt#2", "UPDATE moderation_log SET actingid= ? WHERE actingid = ?", []any{deletedUserID, userid}})
98 | rawTriples = append(rawTriples, Triplet{"registrations stmt", "DELETE FROM registrations where userid = ?", []any{userid}})
99 | }
100 |
101 | /* REMOVING CREDENTIALS */
102 | if !keepUsername {
103 | // remove the account entirely
104 | rawTriples = append(rawTriples, Triplet{"delete user stmt", "DELETE FROM users where id = ?", []any{userid}})
105 | } else {
106 | // disable using the account by generating and setting a gibberish password
107 | throwawayPasswordHash, err := crypto.HashPassword(crypto.GeneratePassword())
108 | if rollbackOnErr(ed.Eout(err, fmt.Sprintf("prepare throwaway password"))) {
109 | return
110 | }
111 | rawTriples = append(rawTriples, Triplet{"nullify logins by replacing user password", "UPDATE users SET passwordhash = ? where id = ?", []any{throwawayPasswordHash, userid}})
112 | }
113 |
114 | var preparedStmts []*sql.Stmt
115 |
116 | prepStmt := func(rawStmt string) (*sql.Stmt, error) {
117 | var stmt *sql.Stmt
118 | stmt, err = tx.Prepare(rawStmt)
119 | return stmt, err
120 | }
121 |
122 | for _, triple := range rawTriples {
123 | prep, err := prepStmt(triple.Statement)
124 | if rollbackOnErr(ed.Eout(err, fmt.Sprintf("prepare %s", triple.Desc))) {
125 | return
126 | }
127 | defer prep.Close()
128 | preparedStmts = append(preparedStmts, prep)
129 | }
130 |
131 | for i, stmt := range preparedStmts {
132 | triple := rawTriples[i]
133 | _, err = stmt.Exec(triple.Args...)
134 | if rollbackOnErr(ed.Eout(err, fmt.Sprintf("exec %s", triple.Desc))) {
135 | return
136 | }
137 | }
138 |
139 | err = tx.Commit()
140 | ed.Check(err, "commit transaction")
141 | finalErr = nil
142 | return
143 | }
144 |
145 | func (d DB) AddModerationLog(actingid, recipientid, action int) error {
146 | ed := util.Describe("add moderation log")
147 | t := time.Now()
148 | // we have a recipient
149 | var err error
150 | if recipientid > 0 {
151 | insert := `INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`
152 | _, err = d.Exec(insert, actingid, recipientid, action, t)
153 | } else {
154 | // we are not listing a recipient
155 | insert := `INSERT INTO moderation_log (actingid, action, time) VALUES (?, ?, ?)`
156 | _, err = d.Exec(insert, actingid, action, t)
157 | }
158 | if err = ed.Eout(err, "exec prepared statement"); err != nil {
159 | return err
160 | }
161 | return nil
162 | }
163 |
164 | type ModerationEntry struct {
165 | ActingUsername, RecipientUsername, QuorumUsername string
166 | QuorumDecision bool
167 | Action int
168 | Time time.Time
169 | }
170 |
171 | func (d DB) GetModerationLogs() []ModerationEntry {
172 | ed := util.Describe("moderation log")
173 | query := `SELECT uact.name, urecp.name, uquorum.name, q.decision, m.action, m.time
174 | FROM moderation_LOG m
175 |
176 | LEFT JOIN users uact ON uact.id = m.actingid
177 | LEFT JOIN users urecp ON urecp.id = m.recipientid
178 |
179 | LEFT JOIN quorum_decisions q ON q.modlogid = m.id
180 | LEFT JOIN users uquorum ON uquorum.id = q.userid
181 |
182 | ORDER BY time DESC`
183 |
184 | stmt, err := d.db.Prepare(query)
185 | defer stmt.Close()
186 | ed.Check(err, "prep stmt")
187 |
188 | rows, err := stmt.Query()
189 | defer rows.Close()
190 | util.Check(err, "run query")
191 |
192 | var logs []ModerationEntry
193 | for rows.Next() {
194 | var entry ModerationEntry
195 | var actingUsername, recipientUsername, quorumUsername sql.NullString
196 | var quorumDecision sql.NullBool
197 | if err := rows.Scan(&actingUsername, &recipientUsername, &quorumUsername, &quorumDecision, &entry.Action, &entry.Time); err != nil {
198 | ed.Check(err, "scanning loop")
199 | }
200 | if actingUsername.Valid {
201 | entry.ActingUsername = actingUsername.String
202 | }
203 | if recipientUsername.Valid {
204 | entry.RecipientUsername = recipientUsername.String
205 | }
206 | if quorumUsername.Valid {
207 | entry.QuorumUsername = quorumUsername.String
208 | }
209 | if quorumDecision.Valid {
210 | entry.QuorumDecision = quorumDecision.Bool
211 | }
212 | logs = append(logs, entry)
213 | }
214 | return logs
215 | }
216 |
217 | func (d DB) ProposeModerationAction(proposerid, recipientid, action int) (finalErr error) {
218 | ed := util.Describe("propose mod action")
219 |
220 | t := time.Now()
221 | tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{})
222 | ed.Check(err, "open transaction")
223 |
224 | rollbackOnErr := func(incomingErr error) bool {
225 | if incomingErr != nil {
226 | _ = tx.Rollback()
227 | log.Println(incomingErr, "rolling back")
228 | finalErr = incomingErr
229 | return true
230 | }
231 | return false
232 | }
233 |
234 | // start tx
235 | propRecipientId := -1
236 | // there should only be one pending proposal of each type for any given recipient
237 | // so let's check to make sure that's true!
238 | stmt, err := tx.Prepare("SELECT recipientid FROM moderation_proposals WHERE action = ?")
239 | defer stmt.Close()
240 | err = stmt.QueryRow(action).Scan(&propRecipientId)
241 | if err == nil && propRecipientId != -1 {
242 | finalErr = tx.Commit()
243 | return
244 | }
245 | // there was no pending proposal of the proposed action for recipient - onwards!
246 |
247 | // add the proposal
248 | stmt, err = tx.Prepare("INSERT INTO moderation_proposals (proposerid, recipientid, time, action) VALUES (?, ?, ?, ?)")
249 | defer stmt.Close()
250 | if rollbackOnErr(ed.Eout(err, "prepare proposal stmt")) {
251 | return
252 | }
253 | _, err = stmt.Exec(proposerid, recipientid, t, action)
254 | if rollbackOnErr(ed.Eout(err, "insert into proposals table")) {
255 | return
256 | }
257 |
258 | // TODO (2023-12-18): hmm how do we do this properly now? only have one constant per action
259 | // {demote, make admin, remove user} but vary translations for these three depending on if there is also a decision or not?
260 |
261 | // add moderation log that user x proposed action y for recipient z
262 | stmt, err = tx.Prepare(`INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`)
263 | defer stmt.Close()
264 | if rollbackOnErr(ed.Eout(err, "prepare modlog stmt")) {
265 | return
266 | }
267 | _, err = stmt.Exec(proposerid, recipientid, action, t)
268 | if rollbackOnErr(ed.Eout(err, "insert into modlog")) {
269 | return
270 | }
271 |
272 | err = tx.Commit()
273 | ed.Check(err, "commit transaction")
274 | return
275 | }
276 |
277 | type ModProposal struct {
278 | ActingUsername, RecipientUsername string
279 | ActingID, RecipientID int
280 | ProposalID, Action int
281 | Time time.Time
282 | }
283 |
284 | func (d DB) GetProposedActions() []ModProposal {
285 | ed := util.Describe("get moderation proposals")
286 | stmt, err := d.db.Prepare(`SELECT mp.id, proposerid, up.name, recipientid, ur.name, action, mp.time
287 | FROM moderation_proposals mp
288 | INNER JOIN users up on mp.proposerid = up.id
289 | INNER JOIN users ur on mp.recipientid = ur.id
290 | ORDER BY time DESC
291 | ;`)
292 | defer stmt.Close()
293 | ed.Check(err, "prepare stmt")
294 | rows, err := stmt.Query()
295 | ed.Check(err, "perform query")
296 | defer rows.Close()
297 | var proposals []ModProposal
298 | for rows.Next() {
299 | var prop ModProposal
300 | if err = rows.Scan(&prop.ProposalID, &prop.ActingID, &prop.ActingUsername, &prop.RecipientID, &prop.RecipientUsername, &prop.Action, &prop.Time); err != nil {
301 | ed.Check(err, "error scanning in row data")
302 | }
303 | proposals = append(proposals, prop)
304 | }
305 | return proposals
306 | }
307 |
308 | // finalize a proposal by either confirming or vetoing it, logging the requisite information and then finally executing
309 | // the proposed action itself
310 | func (d DB) FinalizeProposedAction(proposalid, adminid int, decision bool) (finalErr error) {
311 | ed := util.Describe("finalize proposed mod action")
312 |
313 | t := time.Now()
314 | tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{})
315 | ed.Check(err, "open transaction")
316 |
317 | rollbackOnErr := func(incomingErr error) bool {
318 | if incomingErr != nil {
319 | _ = tx.Rollback()
320 | log.Println(incomingErr, "rolling back")
321 | finalErr = incomingErr
322 | return true
323 | }
324 | return false
325 | }
326 |
327 | /* start tx */
328 | // make sure the proposal is still there (i.e. nobody has beat us to acting on it yet)
329 | stmt, err := tx.Prepare("SELECT 1 FROM moderation_proposals WHERE id = ?")
330 | defer stmt.Close()
331 | if rollbackOnErr(ed.Eout(err, "prepare proposal existence stmt")) {
332 | return
333 | }
334 | existence := -1
335 | err = stmt.QueryRow(proposalid).Scan(&existence)
336 | // proposal id did not exist (it was probably already acted on!)
337 | if err != nil {
338 | _ = tx.Commit()
339 | return
340 | }
341 | // retrieve the proposal & populate with our dramatis personae
342 | var proposerid, recipientid, proposalAction int
343 | var proposalDate time.Time
344 | stmt, err = tx.Prepare(`SELECT proposerid, recipientid, action, time from moderation_proposals WHERE id = ?`)
345 | defer stmt.Close()
346 | err = stmt.QueryRow(proposalid).Scan(&proposerid, &recipientid, &proposalAction, &proposalDate)
347 | if rollbackOnErr(ed.Eout(err, "retrieve proposal vals")) {
348 | return
349 | }
350 |
351 | isSelfConfirm := proposerid == adminid
352 | timeSelfConfirmOK := proposalDate.Add(constants.PROPOSAL_SELF_CONFIRMATION_WAIT)
353 | // TODO (2024-01-07): render err message in admin view?
354 | // self confirms are not allowed at this point in time, exit early without performing any changes
355 | if isSelfConfirm && (decision == constants.PROPOSAL_CONFIRM && !time.Now().After(timeSelfConfirmOK)) {
356 | err = tx.Commit()
357 | ed.Check(err, "commit transaction")
358 | finalErr = nil
359 | return
360 | }
361 |
362 | // convert proposed action (semantically different for the sake of logs) from the finalized action
363 | var action int
364 | switch proposalAction {
365 | case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
366 | action = constants.MODLOG_ADMIN_DEMOTE
367 | case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
368 | action = constants.MODLOG_REMOVE_USER
369 | case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
370 | action = constants.MODLOG_ADMIN_MAKE
371 | default:
372 | ed.Check(errors.New("unknown proposal action"), "convertin proposalAction into action")
373 | }
374 |
375 | // remove proposal from proposal table as it has been executed as desired
376 | stmt, err = tx.Prepare("DELETE FROM moderation_proposals WHERE id = ?")
377 | defer stmt.Close()
378 | if rollbackOnErr(ed.Eout(err, "prepare proposal removal stmt")) {
379 | return
380 | }
381 | _, err = stmt.Exec(proposalid)
382 | if rollbackOnErr(ed.Eout(err, "remove proposal from table")) {
383 | return
384 | }
385 |
386 | // add moderation log
387 | stmt, err = tx.Prepare(`INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`)
388 | defer stmt.Close()
389 | if rollbackOnErr(ed.Eout(err, "prepare modlog stmt")) {
390 | return
391 | }
392 | // the admin who proposed the action will be logged as the one performing it
393 | // get the modlog so we can reference it in the quorum_decisions table. this will be used to augment the moderation
394 | // log view with quorum info
395 | result, err := stmt.Exec(proposerid, recipientid, action, t)
396 | if rollbackOnErr(ed.Eout(err, "insert into modlog")) {
397 | return
398 | }
399 | modlogid, err := result.LastInsertId()
400 | if rollbackOnErr(ed.Eout(err, "get last insert id")) {
401 | return
402 | }
403 |
404 | // update the quorum decisions table so that we can use its info to augment the moderation log view
405 | stmt, err = tx.Prepare(`INSERT INTO quorum_decisions (userid, decision, modlogid) VALUES (?, ?, ?)`)
406 | defer stmt.Close()
407 | if rollbackOnErr(ed.Eout(err, "prepare quorum insertion stmt")) {
408 | return
409 | }
410 | // decision = confirm or veto => values true or false
411 | _, err = stmt.Exec(adminid, decision, modlogid)
412 | if rollbackOnErr(ed.Eout(err, "execute quorum insertion")) {
413 | return
414 | }
415 |
416 | err = tx.Commit()
417 | ed.Check(err, "commit transaction")
418 |
419 | // the decision was to veto the proposal: there's nothing more to do! except return outta this function ofc ofc
420 | if decision == constants.PROPOSAL_VETO {
421 | return
422 | }
423 | // perform the actual action; would be preferable to do this in the transaction somehow
424 | // but hell no am i copying in those bits here X)
425 | switch proposalAction {
426 | case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN:
427 | err = d.DemoteAdmin(recipientid)
428 | ed.Check(err, "remove user", recipientid)
429 | case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER:
430 | // TODO (2025-04-13): introduce/record proposal granularity for admin delete view wrt these booleans
431 | err = d.RemoveUser(recipientid, RemoveUserOptions{KeepContent: false, KeepUsername: false})
432 | ed.Check(err, "remove user", recipientid)
433 | case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN:
434 | d.AddAdmin(recipientid)
435 | ed.Check(err, "add admin", recipientid)
436 | }
437 | return
438 | }
439 |
440 | type User struct {
441 | Name string
442 | ID int
443 | RegistrationOrigin string
444 | }
445 |
446 | func (d DB) AddAdmin(userid int) error {
447 | ed := util.Describe("add admin")
448 | // make sure the id exists
449 | exists, err := d.CheckUserExists(userid)
450 | if !exists {
451 | return fmt.Errorf("add admin: userid %d did not exist", userid)
452 | }
453 | if err != nil {
454 | return ed.Eout(err, "CheckUserExists had an error")
455 | }
456 | isAdminAlready, err := d.IsUserAdmin(userid)
457 | if isAdminAlready {
458 | return fmt.Errorf("userid %d was already an admin", userid)
459 | }
460 | if err != nil {
461 | // some kind of error, let's bubble it up
462 | return ed.Eout(err, "IsUserAdmin")
463 | }
464 | // insert into table, we gots ourselves a new sheriff in town [|:D
465 | stmt := `INSERT INTO admins (id) VALUES (?)`
466 | _, err = d.db.Exec(stmt, userid)
467 | if err != nil {
468 | return ed.Eout(err, "inserting new admin")
469 | }
470 | return nil
471 | }
472 |
473 | func (d DB) DemoteAdmin(userid int) error {
474 | ed := util.Describe("demote admin")
475 | // make sure the id exists
476 | exists, err := d.CheckUserExists(userid)
477 | if !exists {
478 | return fmt.Errorf("demote admin: userid %d did not exist", userid)
479 | }
480 | if err != nil {
481 | return ed.Eout(err, "CheckUserExists had an error")
482 | }
483 | isAdmin, err := d.IsUserAdmin(userid)
484 | if !isAdmin {
485 | return fmt.Errorf("demote admin: userid %d was not an admin", userid)
486 | }
487 | if err != nil {
488 | // some kind of error, let's bubble it up
489 | return ed.Eout(err, "IsUserAdmin")
490 | }
491 | // all checks are done: perform the removal
492 | stmt := `DELETE FROM admins WHERE id = ?`
493 | _, err = d.db.Exec(stmt, userid)
494 | if err != nil {
495 | return ed.Eout(err, "inserting new admin")
496 | }
497 | return nil
498 | }
499 |
500 | func (d DB) IsUserAdmin(userid int) (bool, error) {
501 | stmt := `SELECT 1 FROM admins WHERE id = ?`
502 | return d.existsQuery(stmt, userid)
503 | }
504 |
505 | func (d DB) QuorumActivated() bool {
506 | admins := d.GetAdmins()
507 | return len(admins) >= 2
508 | }
509 |
510 | func (d DB) GetAdmins() []User {
511 | ed := util.Describe("get admins")
512 | query := `SELECT u.name, a.id
513 | FROM users u
514 | INNER JOIN admins a ON u.id = a.id
515 | ORDER BY u.name
516 | `
517 | stmt, err := d.db.Prepare(query)
518 | defer stmt.Close()
519 | ed.Check(err, "prep stmt")
520 |
521 | rows, err := stmt.Query()
522 | defer rows.Close()
523 | util.Check(err, "run query")
524 |
525 | var user User
526 | var admins []User
527 | for rows.Next() {
528 | if err := rows.Scan(&user.Name, &user.ID); err != nil {
529 | ed.Check(err, "scanning loop")
530 | }
531 | admins = append(admins, user)
532 | }
533 | return admins
534 | }
535 |
536 | type InviteBatch struct {
537 | BatchId string
538 | ActingUsername string
539 | UnclaimedInvites []string
540 | Label string
541 | Time time.Time
542 | Reusable bool
543 | }
544 |
545 | func (d DB) ClaimInvite(invite string) (bool, string, error) {
546 | ed := util.Describe("claim invite")
547 | var err error
548 | var tx *sql.Tx
549 | tx, err = d.db.BeginTx(context.Background(), &sql.TxOptions{})
550 |
551 | rollbackOnErr := func(incomingErr error) error {
552 | if incomingErr != nil {
553 | _ = tx.Rollback()
554 | log.Println(incomingErr, "rolling back")
555 | return incomingErr
556 | }
557 | return nil
558 | }
559 |
560 | type BatchQuery struct {
561 | stmt, desc string
562 | preparedStmt *sql.Stmt
563 | }
564 |
565 | ops := []BatchQuery{
566 | BatchQuery{desc: "check if invite to redeem exists", stmt: "SELECT EXISTS (SELECT 1 FROM invites WHERE invite = ?)"},
567 | BatchQuery{desc: "get invite code's batchid and whether marked reusable", stmt: "SELECT batchid, reusable FROM invites WHERE invite = ?"},
568 | BatchQuery{desc: "delete invite from table", stmt: "DELETE FROM invites WHERE invite = ?"},
569 | }
570 |
571 | for i, operation := range ops {
572 | ops[i].preparedStmt, err = tx.Prepare(operation.stmt)
573 | defer ops[i].preparedStmt.Close()
574 | if e := rollbackOnErr(ed.Eout(err, operation.desc)); e != nil {
575 | return false, "", e
576 | }
577 | }
578 |
579 | // first: check if the invite still exists; uses QueryRow to get back results
580 | row := ops[0].preparedStmt.QueryRow(invite)
581 | var exists int
582 | err = row.Scan(&exists)
583 | if e := rollbackOnErr(ed.Eout(err, "exec "+ops[0].desc)); e != nil {
584 | return false, "", e
585 | }
586 |
587 | // existence check failed. end transaction by rolling back (nothing meaningful was changed)
588 | if exists == 0 {
589 | _ = tx.Rollback()
590 | return false, "", nil
591 | }
592 |
593 | // then: get the associated batchid, so we can associate it with the registration
594 | row = ops[1].preparedStmt.QueryRow(invite)
595 | var batchid string // uuid v4
596 | var reusable bool
597 | err = row.Scan(&batchid, &reusable)
598 | if e := rollbackOnErr(ed.Eout(err, "exec "+ops[1].desc)); e != nil {
599 | return false, "", e
600 | }
601 |
602 | if !reusable {
603 | // then, finally: delete the invite code being claimed
604 | _, err = ops[2].preparedStmt.Exec(invite)
605 | if e := rollbackOnErr(ed.Eout(err, "exec "+ops[2].desc)); e != nil {
606 | return false, "", e
607 | }
608 | }
609 |
610 | err = tx.Commit()
611 | ed.Check(err, "commit transaction")
612 | return true, batchid, nil
613 | }
614 |
615 | const maxBatchAmount = 100
616 | const maxUnclaimedAmount = 500
617 |
618 | func (d DB) CreateInvites(adminid int, amount int, label string, reusable bool) error {
619 | ed := util.Describe("create invites")
620 | isAdmin, err := d.IsUserAdmin(adminid)
621 | if err != nil {
622 | return ed.Eout(err, "IsUserAdmin")
623 | }
624 |
625 | if !isAdmin {
626 | return fmt.Errorf("userid %d was not an admin, they can't create an invite", adminid)
627 | }
628 |
629 | // check that amount is within reasonable range
630 | if amount > maxBatchAmount {
631 | return fmt.Errorf("batch amount should not exceed %d but was %d; not creating invites ", maxBatchAmount, amount)
632 | }
633 |
634 | // check that already existing unclaimed invites is within a reasonable range
635 | stmt := "SELECT COUNT(*) FROM invites"
636 | var unclaimed int
637 | err = d.db.QueryRow(stmt).Scan(&unclaimed)
638 | ed.Check(err, "querying for number of unclaimed invites")
639 | if unclaimed > maxUnclaimedAmount {
640 | msgstr := "number of unclaimed invites amount should not exceed %d but was %d; ceasing invite creation"
641 | return fmt.Errorf(msgstr, maxUnclaimedAmount, unclaimed)
642 | }
643 |
644 | // all cleared!
645 | invites := make([]string, 0, amount)
646 | for i := 0; i < amount; i++ {
647 | invites = append(invites, util.GetUUIDv4())
648 | }
649 | // adjust the amount that will be created if we are near the unclaimed amount threshold
650 | if (amount + unclaimed) > maxUnclaimedAmount {
651 | amount = maxUnclaimedAmount - unclaimed
652 | }
653 |
654 | if amount <= 0 {
655 | return fmt.Errorf("number of unclaimed invites amount %d has been reached; not creating invites ", maxUnclaimedAmount)
656 | }
657 |
658 | // this id identifies all invites from this batch
659 | batchid := util.GetUUIDv4()
660 | creationTime := time.Now()
661 | preparedStmt, err := d.db.Prepare("INSERT INTO invites (batchid, adminid, invite, label, time, reusable) VALUES (?, ?, ?, ?, ?, ?)")
662 | util.Check(err, "prepare invite insert stmt")
663 | defer preparedStmt.Close()
664 | for _, invite := range invites {
665 | // create a batch
666 | _, err := preparedStmt.Exec(batchid, adminid, invite, label, creationTime, reusable)
667 | ed.Check(err, "inserting invite into database")
668 | }
669 | return nil
670 | }
671 |
672 | func (d DB) DestroyInvites(invites []string) {
673 | ed := util.Describe("destroy invites")
674 | stmt := "DELETE FROM invites WHERE invite = ?"
675 | for _, invite := range invites {
676 | _, err := d.Exec(stmt, invite)
677 | // note: it's okay if one of the statements fails, maybe someone lucked out and redeemed it in the middle of the
678 | // loop - whatever
679 | if err != nil {
680 | log.Println(ed.Eout(err, "err during exec"))
681 | }
682 | }
683 | }
684 |
685 | func (d DB) DeleteInvitesBatch(batchid string) {
686 | ed := util.Describe("delete invites by batchid")
687 |
688 | stmt, err := d.db.Prepare("DELETE FROM invites where batchid = ?")
689 | ed.Check(err, "prep stmt")
690 | defer stmt.Close()
691 |
692 | _, err = stmt.Exec(batchid)
693 | util.Check(err, "execute delete")
694 | }
695 |
696 | func (d DB) GetAllInvites() []InviteBatch {
697 | ed := util.Describe("get all invites")
698 |
699 | rows, err := d.db.Query("SELECT i.batchid, u.name, i.invite, i.time, i.label, i.reusable FROM invites i INNER JOIN users u ON i.adminid = u.id")
700 | ed.Check(err, "create query")
701 |
702 | // keep track of invite batches by creating a key based on username + creation time
703 | batches := make(map[string]*InviteBatch)
704 | var keys []string
705 | var batchid, invite, username, label string
706 | var t time.Time
707 | var reusable bool
708 |
709 | for rows.Next() {
710 | err := rows.Scan(&batchid, &username, &invite, &t, &label, &reusable)
711 | ed.Check(err, "scan row")
712 | // starting the key with the unix epoch as a string allows us to sort the map's keys by time just by comparing strings with sort.Strings()
713 | unixTimestamp := strconv.FormatInt(t.Unix(), 10)
714 | key := unixTimestamp + username
715 | if batch, exists := batches[key]; exists {
716 | batch.UnclaimedInvites = append(batch.UnclaimedInvites, invite)
717 | } else {
718 | keys = append(keys, key)
719 | batches[key] = &InviteBatch{BatchId: batchid, ActingUsername: username, UnclaimedInvites: []string{invite}, Label: label, Time: t, Reusable: reusable}
720 | }
721 | }
722 |
723 | // convert from map to a []InviteBatch sorted by time using ts-prefixed map keys
724 | ret := make([]InviteBatch, 0, len(keys))
725 | // we want newest first
726 | sort.Sort(sort.Reverse(sort.StringSlice(keys)))
727 | for _, key := range keys {
728 | ret = append(ret, *batches[key])
729 | }
730 | return ret
731 | }
732 |
733 | type RegisteredInvite struct {
734 | Label string
735 | BatchID string
736 | Count int
737 | }
738 |
739 | func (d DB) CountRegistrationsByInviteBatch() []RegisteredInvite {
740 | ed := util.Describe("database/moderation.go: count registrations by invite batch")
741 | stmt := `SELECT i.label, i.batchid, COUNT(*)
742 | FROM registrations r INNER JOIN invites i
743 | ON r.link == i.batchid
744 | GROUP BY i.batchid
745 | ORDER BY COUNT(*) DESC
746 | ;`
747 | rows, err := d.db.Query(stmt)
748 | ed.Check(err, "query stmt")
749 | var registrations []RegisteredInvite
750 | for rows.Next() {
751 | var info RegisteredInvite
752 | err = rows.Scan(&info.Label, &info.BatchID, &info.Count)
753 | ed.Check(err, "failed to scan returned result")
754 | registrations = append(registrations, info)
755 | }
756 | return registrations
757 | }
758 |
--------------------------------------------------------------------------------
/defaults/defaults.go:
--------------------------------------------------------------------------------
1 | package defaults
2 |
3 | import (
4 | _ "embed"
5 | )
6 |
7 | //go:embed sample-about.md
8 | var DEFAULT_ABOUT string
9 |
10 | //go:embed sample-logo.html
11 | var DEFAULT_LOGO string
12 |
13 | //go:embed sample-rules.md
14 | var DEFAULT_RULES string
15 |
16 | //go:embed sample-registration-instructions.md
17 | var DEFAULT_REGISTRATION string
18 |
19 | //go:embed sample-config.toml
20 | var DEFAULT_CONFIG string
21 |
22 | //go:embed sample-theme.css
23 | var DEFAULT_THEME string
24 |
--------------------------------------------------------------------------------
/defaults/sample-about.md:
--------------------------------------------------------------------------------
1 | # About
2 | This forum is for and by the [Merveilles](https://wiki.xxiivv.com/site/merveilles.html)
3 | community.
4 |
5 | The [forum software](https://github.com/cblgh/cerca) itself was created from scratch by
6 | [cblgh](https://cblgh.org) at the start of 2022, after a long time of pining for a new wave of
7 | forums hangs.
8 |
9 | If you are from Merveilles: [register](/register) an account. If you're a passerby, feel free
10 | to read the [public threads](/).
11 |
12 | ## Code of conduct
13 | As with all Merveilles spaces, this forum abides by the compact set out in the [Mervilles Code
14 | of Conduct](https://github.com/merveilles/Resources/blob/master/CONDUCT.md).
15 |
16 | ## Forum syntax
17 | Posts in the forum are made using [Markdown syntax](https://en.wikipedia.org/wiki/Markdown#Examples).
18 |
19 | \*\*Bold text\*\* and \*italics\*
20 |
21 |
22 | * lists
23 | * like
24 | * this
25 |
26 |
27 | > Blockquote
28 |
29 | \`typewriter text\`
30 |
31 |
32 | ```
33 | blocks of
34 | code like
35 | this
36 | ```
37 |
38 |
39 | Create links like \[this\]\(url\)
, and embed images like: !\[description\]\(url\)
. Note how the image
40 | syntax's exclamation mark precedes the regular link syntax.
41 |
42 | Each post in the thread can be referenced like \[this post\]\(#12\)
, where 12 is the post number which can be
43 | found at each post timestamp.
44 |
45 | this is one paragraph.
46 | this belongs to the same paragraph.
47 |
48 | this is a new paragraph
49 |
50 |
--------------------------------------------------------------------------------
/defaults/sample-config.toml:
--------------------------------------------------------------------------------
1 | [general]
2 | name = "" # whatever you want to name your forum; primarily used as display in tab titles
3 | conduct_url = "" # optional + recommended: if omitted, the CoC checkboxes in /register will be hidden
4 | language = "English" # Swedish, English. contributions for more translations welcome!
5 |
6 | [rss]
7 | feed_name = "" # defaults to [general]'s name if unset
8 | feed_description = ""
9 | forum_url = "" # should be forum index route https://example.com. used to generate post routes for feed, must be set to generate a feed
10 |
11 | [documents]
12 | logo = "content/logo.html" # can contain emoji, , etc. see defaults/sample-logo.html in repo for instructions
13 | about = "content/about.md"
14 | rules = "content/rules.md"
15 | registration_instructions = "content/registration-instructions.md"
16 |
--------------------------------------------------------------------------------
/defaults/sample-logo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
14 |
17 |
--------------------------------------------------------------------------------
/defaults/sample-registration-instructions.md:
--------------------------------------------------------------------------------
1 | Request an invite code from one of the forum admins.
2 |
--------------------------------------------------------------------------------
/defaults/sample-rules.md:
--------------------------------------------------------------------------------
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).
2 |
--------------------------------------------------------------------------------
/defaults/sample-theme.css:
--------------------------------------------------------------------------------
1 | /* below are the style rules that define the visual theme of the forum
2 | * change these to change the forum's colours */
3 |
4 | /* NORMAL THEME */
5 | /* default theme colors:
6 | * black
7 | * wheat - bg
8 | * #666 - visited link
9 | * darkred - highlights: links, certain tags
10 | */
11 | /*
12 | body { background: wheat; color: black; }
13 | textarea { background: black; color: wheat; }
14 | ul li { list-style-type: circle; }
15 | blockquote { border-left-color: darkred; }
16 | a:not([class]), pre code { color: darkred; }
17 | h1, h2, h2 > a:not([class], :visited), span > b { color: black; }
18 | a:visited { color: #666; }
19 | header a, header a:visited { color: darkred; }
20 | */
21 | /* post author name is styled with `span > b` */
22 | /* span > b { color: black; } */
23 |
24 | /* HALLOWEEN THEME */
25 | /*
26 | /* halloween theme colors
27 | * #ff8000
28 | * wheat
29 | * gray
30 | * #111
31 | * #f2f2f2
32 | */
33 | /*
34 | header svg { fill: #ff8000; }
35 | #logo {
36 | width: 48px;
37 | height: 48px;
38 | display: block;
39 | }
40 | p { color: #f2f2f2; }
41 | blockquote { border-color: wheat; }
42 | textarea { background: black; color: wheat; }
43 | h1 a:visited, a:not([class]) { color: wheat; }
44 | a:visited { color: gray; }
45 | body { background: #111; color: #ff8000; }
46 | header details a:visited { color: #ff8000; }
47 |
48 | /* author colors */
49 | /*
50 | span > b { color: #ff8000; }
51 | */
52 |
53 |
--------------------------------------------------------------------------------
/docs/accessibility.md:
--------------------------------------------------------------------------------
1 | # Accessibility
2 |
3 | ## Alt text for images
4 |
5 | Cerca supports the usual Markdown syntax:
6 |
7 | ```markdown
8 | 
9 | ```
10 |
11 | This alt text is also used as the `title=...` attribute which will provide an
12 | alt text description when hovering over the image.
13 |
--------------------------------------------------------------------------------
/docs/faq.md:
--------------------------------------------------------------------------------
1 | # FAQ
2 |
3 | ## Can a private thread be made public?
4 |
5 | The simple answer is: **no**. There is no way in the interface of reverting the private status of the thread for any kind of user; this is intentional. We want to preserve the expectations of the posters of that thread.
6 |
7 | The too-detailed answer is that the person/people with access to the underlying database can technically flip the bit that has the thread as private thereby making it public.
8 |
9 | ## RSS
10 |
11 | ### Where is my feed?
12 |
13 | In the menu, on the top right, you'll see a `rss` menu item.
14 |
15 | It's also available as `/rss.xml` on the end of your Cerca URL.
16 |
17 | ### Are private threads included in the feed?
18 |
19 | Not yet. See [`#70`](https://github.com/cblgh/cerca/issues/70) for more.
20 |
21 | ### How is the feed generated?
22 |
23 | The feed is intentionally low volume.
24 |
25 | A feed item is generated per-thread. Only the latest poster is included in each feed item. When a new post is made in a thread, the feed item is updated "in place", in other words, the existing feed item is replaced without adding a new feed item.
26 |
27 | No post content is included in the item. Instead, the feed is intended as low-tech notification mechanism, a reminder to revisit the forum and to join in on discussions that catch your eye.
28 |
--------------------------------------------------------------------------------
/docs/hosting.md:
--------------------------------------------------------------------------------
1 | # Hosting
2 |
3 | ## System user
4 |
5 | You can use a system user with no login:
6 |
7 | ```
8 | useradd -r cerca
9 | usermod -s /bin/false cerca
10 | ```
11 |
12 | ## Nginx configuration
13 |
14 | ```
15 | server {
16 | listen 80;
17 | listen 443 ssl;
18 |
19 | server_name ;
20 |
21 | location / {
22 | proxy_set_header X-Real-IP $remote_addr;
23 | proxy_pass http://127.0.0.1:8272;
24 | }
25 |
26 | # NOTE: only required if running cerca via a standalone binary
27 | # vs. a git clone where it will have access to the assets dir
28 | location /assets/ {
29 | root ;
30 | }
31 | }
32 | ```
33 |
34 | ## Systemd unit file
35 |
36 | This can be placed at `/etc/systemd/system/cerca.service`:
37 |
38 | ```
39 | [Unit]
40 | Description=cerca
41 | After=syslog.target network.target
42 |
43 | [Service]
44 | User=cerca
45 | ExecStart= -config -authkey "<...>" -allowlist -data
46 | RemainAfterExit=no
47 | Restart=always
48 | RestartSec=5
49 |
50 | [Install]
51 | WantedBy=multi-user.target
52 | ```
53 |
54 | Then you need to:
55 |
56 | ```
57 | systemctl daemon-reload
58 | systemctl start cerca
59 | systemctl enable cerca
60 | ```
61 |
62 | To tail logs:
63 |
64 | ```
65 | journalctl -fu cerca
66 | ```
67 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module cerca
2 |
3 | go 1.21
4 |
5 | toolchain go1.23.0
6 |
7 | require (
8 | github.com/cblgh/plain v0.0.3
9 | github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df
10 | github.com/google/uuid v1.6.0
11 | github.com/gorilla/sessions v1.2.1
12 | github.com/komkom/toml v0.1.2
13 | github.com/matthewhartstonge/argon2 v1.0.0
14 | github.com/mattn/go-sqlite3 v1.14.19
15 | github.com/microcosm-cc/bluemonday v1.0.26
16 | golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e
17 | golang.org/x/time v0.3.0
18 | )
19 |
20 | require (
21 | github.com/aymerick/douceur v0.2.0 // indirect
22 | github.com/gorilla/css v1.0.0 // indirect
23 | github.com/gorilla/securecookie v1.1.1 // indirect
24 | github.com/pkg/errors v0.9.1 // indirect
25 | github.com/stretchr/testify v1.7.0 // indirect
26 | golang.org/x/crypto v0.16.0 // indirect
27 | golang.org/x/net v0.17.0 // indirect
28 | golang.org/x/sys v0.15.0 // indirect
29 | )
30 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
2 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
3 | github.com/cblgh/plain v0.0.3 h1:hgoHfHHqRRYHYrvUni91kdPbo64mdIj936tlEFDDmKU=
4 | github.com/cblgh/plain v0.0.3/go.mod h1:iHfy3TdwIMAXDqAANAsWfLTEZPv0P+npdo/IoXKsewc=
5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
8 | github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
9 | github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df h1:M7mdNDTRraBcrHZg2aOYiFP9yTDajb6fquRZRpXnbVA=
10 | github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
11 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
12 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
13 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
14 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
15 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
16 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
17 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
18 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
19 | github.com/komkom/toml v0.1.2 h1:SexwnY3JOR0kU9F/xxw/129BPCvuKi6/E89PZ4kSSBo=
20 | github.com/komkom/toml v0.1.2/go.mod h1:cgnL/ntRyMHaZuDy9wREJHWY1Cb2HEINK7U0YhpcTa8=
21 | github.com/matthewhartstonge/argon2 v1.0.0 h1:e65fkae6O8Na6YTy2HAccUbXR+GQHOnpQxeWGqWCRIw=
22 | github.com/matthewhartstonge/argon2 v1.0.0/go.mod h1:Fm4FHZxdxCM6hg21Jkz3YZVKnU7VnTlqDQ3ghS/Myok=
23 | github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
24 | github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
25 | github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
26 | github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
27 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
28 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
31 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
32 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
33 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
34 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
35 | golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
36 | golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
37 | golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
38 | golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e h1:SkwG94eNiiYJhbeDE018Grw09HIN/KB9NlRmZsrzfWs=
39 | golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
40 | golang.org/x/image v0.0.0-20220321031419-a8550c1d254a/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
41 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
42 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
43 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
44 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
45 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
46 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
47 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
48 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
50 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
51 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
52 |
--------------------------------------------------------------------------------
/html/about-template.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 |
3 |
4 | {{ .Data }}
5 |
6 |
7 | {{ template "footer" . }}
8 |
--------------------------------------------------------------------------------
/html/about.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 |
3 |
4 | About
5 | This forum is for and by the Merveilles community.
6 | The forum software itself was created from scratch by cblgh at the start of 2022, after a long time
7 | of pining for a new wave of forums hangs.
8 |
9 | If you are from Merveilles: register an account. If you're a passerby, feel free to read the public threads .
10 |
11 | Code of conduct
12 | As with all Merveilles spaces, this forum abides by the compact set out in the Merveilles Code of Conduct .
13 |
14 | Forum syntax
15 | Posts in the forum are made using Markdown
16 | syntax .
17 | **Bold text** and *italics*
18 |
19 |
20 | * lists
21 | * like
22 | * this
23 |
24 |
25 |
> Blockquote
26 |
27 | `typewriter text`
28 |
29 |
30 | ```
31 | blocks of
32 | code like
33 | this
34 | ```
35 |
36 |
37 | Create links like [this](url)
, and embed images like: 
. Note how the image
38 | syntax's exclamation mark precedes the regular link syntax.
39 |
40 | Each post in the thread can be referenced like [this post](#12)
, where 12 is the post number which can be
41 | found at each post timestamp.
42 |
43 | this is one paragraph.
44 | this belongs to the same paragraph.
45 |
46 | this is a new paragraph
47 |
48 |
49 |
50 | {{ template "footer" . }}
51 |
--------------------------------------------------------------------------------
/html/account.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 |
3 | {{ .Title }}
4 | The place to make account changes. In order to make any change, you need to confirm with your current password.
5 |
6 | {{ if .Data.ErrorMessage }}
7 |
8 |
{{ .Data.ErrorMessage }}
9 |
10 | {{ end }}
11 |
12 | Change password
13 |
14 |
29 |
30 |
31 |
32 | Change username
33 |
50 |
51 |
52 |
53 | Delete account
54 |
106 |
107 |
108 |
109 | {{ template "footer" . }}
110 |
--------------------------------------------------------------------------------
/html/admin-add-user.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 |
3 | {{ .Title }}
4 | {{ "AdminAddUserExplanation" | translate }}
5 |
6 |
13 |
14 | {{ if .Data.ErrorMessage }}
15 |
16 |
{{ .Data.ErrorMessage }}
17 |
18 | {{ end }}
19 |
20 |
21 | {{ template "footer" . }}
22 |
--------------------------------------------------------------------------------
/html/admin-invites.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 |
3 | Invites
4 | Generate an invite and give to people for whom you want to enable account registration.
5 | You can generate many invites at once . If non-admin members are to be enabled to invite new users, then
6 | generate a batch of invite codes and post them in a private thread.
7 | By labeling invites , you can separate different batches. Maybe one batch is for a friend group,
8 | while another will be printed on slips of paper and given out at meetups.
9 | Reusable invites allows a single invite code to be used multiple times without expiring. To
10 | stop the invite from being usable, the invite must be deleted below. Reusable invites provide a
11 | smoother experience for onboarding preexisting community members by posting a reusable invite to a community
12 | space compared to the continual management of invite code batches. However, reusable invites are a possible way for
13 | unauthorized users to gain entry, such as spam accounts, so spread them carefully.
14 |
15 |
16 | Create invites
17 | Create a new batch of invite codes. The maximum amount of invites that can be created at once is 100.
18 |
28 |
29 |
30 |
31 | Unclaimed invites
32 | {{ if len .Data.Batches | eq 0}}
33 | There are currently no unclaimed invite batches that are issused.
34 | {{ else }}
35 | Listed below are batches of invite codes that have yet to be claimed. If all invites from a batch have been used, the batch will no longer be displayed.
36 | {{ end }}
37 | {{ $deleteRoute := .Data.DeleteRoute }}
38 | {{ $forumRoot := .Data.ForumRootURL }}
39 | {{ range $index, $batch := .Data.Batches }}
40 | {{ if $batch.Reusable }}[Reusable] {{ end }}{{ if len $batch.Label | eq 0 }} Unlabeled batch {{ else }} "{{ $batch.Label }}" {{ end }} created {{ $batch.Time | formatDate }} by {{ $batch.ActingUsername }}
41 |
44 | ID for this batch: {{ $batch.BatchId }}
45 | Delete remaining invites in this batch Delete
46 |
47 | Invites as code block
48 |
49 | {{ range $index, $invite := $batch.UnclaimedInvites }}{{ $invite }}
50 | {{ end }}
51 |
52 |
53 |
54 | Invites as pre-filled registration links
55 |
60 |
61 | {{ end }}
62 |
63 |
64 |
65 | {{ if .Data.ErrorMessage }}
66 |
67 |
{{ .Data.ErrorMessage }}
68 |
69 | {{ end }}
70 |
71 |
72 | {{ template "footer" . }}
73 |
--------------------------------------------------------------------------------
/html/admin.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 |
3 | {{ .Title }}
4 |
5 |
6 |
7 |
10 |
11 | Do you want to view or create invites? View invites .
12 |
13 |
14 | {{ "AdminAddNewUserQuestion" | translate }} {{ "AdminAddNewUser" | translate }} .
15 |
16 |
17 | {{ "AdminStepDownExplanation" | translate }} {{ "AdminStepDown" | translate }} .
18 |
19 |
20 | {{ "AdminViewPastActions" | translate }} {{ "ModerationLog" | translate }} .
21 |
22 |
23 |
24 | {{ if .LoggedIn }}
25 | {{ $userID := .LoggedInID }}
26 |
27 | {{ "Admins" | translate | capitalize }}
28 | {{ if len .Data.Admins | eq 0 }}
29 | {{ "AdminNoAdmins" | translate }}
30 | {{ else }}
31 |
32 | {{ range $index, $user := .Data.Admins }}
33 |
34 |
37 | {{ $user.Name }} ({{ $user.ID }})
38 |
39 | {{ if eq $userID $user.ID }} ({{ "AdminYou" | translate }})
40 | {{ else }}{{ "AdminDemote" | translate }} {{ end }}
41 |
42 |
43 | {{ end }}
44 |
45 | {{ end }}
46 |
47 |
48 | {{ "PendingProposals" | translate }}
49 | {{ "AdminPendingExplanation" | translate | tohtml }}
50 | {{ if len .Data.Proposals | eq 0}}
51 | {{ "AdminNoPendingProposals" | translate }}
52 | {{ else }}
53 |
54 |
55 | {{ "Proposal" | translate }}
56 | {{ "AdminSelfProposalsBecomeValid" | translate }}
57 |
58 | {{ range $index, $proposal := .Data.Proposals }}
59 |
60 |
63 |
66 | {{ $proposal.Action | tohtml }}
67 | {{ $proposal.Time | formatDateTime }}
68 | {{ "AdminVeto" | translate }}
69 | {{ $selfProposal := eq $userID $proposal.ProposerID }}
70 | {{"AdminConfirm" | translate}}
71 |
72 | {{ end }}
73 |
74 | {{ end }}
75 |
76 | Registered invites
77 | Tallied invites based on the invite batch. Useful to see how invites are being claimed for different
78 | batches.
79 | {{ if len .Data.Registrations| eq 0 }}
80 | No invites have been redeemed yet.
81 | {{ else }}
82 |
83 |
84 | Label
85 | Uses
86 | ID
87 |
88 | {{ range $index, $reggedInvite := .Data.Registrations }}
89 |
90 | {{ if len $reggedInvite.Label | eq 0 }} Unlabeled batch {{ else }} {{ $reggedInvite.Label }} {{ end }}
91 | {{ $reggedInvite.Count }}
92 | Batch ID {{ $reggedInvite.BatchID }}
93 |
94 | {{ end }}
95 |
96 | {{ end }}
97 |
98 |
99 | {{ "AdminUsers" | translate }}
100 | {{ if len .Data.Users | eq 0 }}
101 | {{ "AdminNoUsers" | translate }}
102 | {{ else }}
103 |
104 | {{ range $index, $user := .Data.Users }}
105 | {{ if and (ne $user.Name "CERCA_CMD") (ne $user.Name "deleted user") }}
106 |
124 | {{ end }}
125 |
126 | {{ end }}
127 |
128 |
129 | {{ end }}
130 |
131 | {{ template "footer" . }}
132 |
--------------------------------------------------------------------------------
/html/admins-list.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 |
3 | {{ .Title }}
4 | {{ if .LoggedIn }}
5 |
6 | {{ "AdminViewPastActions" | translate }} {{ "ModerationLog" | translate }} .
7 | {{ if len .Data.Admins | eq 0 }}
8 | {{ "AdminNoAdmins" | translate }}
9 | {{ else }}
10 | {{ "AdminForumHasAdmins" | translate }}:
11 |
12 | {{ range $index, $user := .Data.Admins }}
13 | {{ $user.Name }}
14 | {{ end }}
15 |
16 |
17 | {{ end }}
18 | {{ else }}
19 | {{ "AdminOnlyLoggedInMayView" | translate }}
20 | {{ end }}
21 |
22 | {{ template "footer" . }}
23 |
--------------------------------------------------------------------------------
/html/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cblgh/cerca/722c38d96160ccf69dd7a8122b62660102b64a59/html/assets/favicon.png
--------------------------------------------------------------------------------
/html/assets/merveilles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cblgh/cerca/722c38d96160ccf69dd7a8122b62660102b64a59/html/assets/merveilles.png
--------------------------------------------------------------------------------
/html/assets/merveilles.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/html/assets/theme.css:
--------------------------------------------------------------------------------
1 | /* below are the style rules that define the visual theme of the forum
2 | * change these to change the forum's colours */
3 |
4 | /* normal theme */
5 | /* default theme colors:
6 | * black
7 | * wheat - bg
8 | * #666 - visited link
9 | * darkred - highlights: links, certain tags
10 | */
11 |
12 | body { background: wheat; color: black; }
13 | textarea { background: black; color: wheat; }
14 | ul li { list-style-type: circle; }
15 | /* post author name is styled with `span > b` */
16 | blockquote { border-left-color: darkred; }
17 | a:not([class]), pre code { color: darkred; }
18 | h1, h2, h2 > a:not([class], :visited), span > b { color: black; }
19 | a:visited { color: #666; }
20 | header a, header a:visited { color: darkred; }
21 |
22 | /* halloween theme */
23 | /*
24 | /* halloween theme colors
25 | * #ff8000
26 | * wheat
27 | * gray
28 | * #111
29 | * #f2f2f2
30 | */
31 | /*
32 | header svg { fill: #ff8000; }
33 | #logo {
34 | width: 48px;
35 | height: 48px;
36 | display: block;
37 | }
38 | p { color: #f2f2f2; }
39 | blockquote { border-color: wheat; }
40 | textarea { background: black; color: wheat; }
41 | h1 a:visited, a:not([class]) { color: wheat; }
42 | a:visited { color: gray; }
43 | body { background: #111; color: #ff8000; }
44 | header details a:visited { color: #ff8000; }
45 |
46 | /* author colors */
47 | /*
48 | span > b { color: #ff8000; }
49 | */
50 |
51 |
--------------------------------------------------------------------------------
/html/change-password-success.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 | {{ "ChangePassword" | translate | capitalize }}
3 | {{ "PasswordResetSuccessMessage" | translate }}
4 |
5 | {{ "RegisterLinkMessage" | translate }} {{ "Index" | translate }} .
6 | {{ template "footer" . }}
7 |
--------------------------------------------------------------------------------
/html/change-password.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 | {{ "ChangePassword" | translate | capitalize }}
3 | {{ "ChangePasswordDescription" | translate }}
4 |
18 | {{ template "footer" . }}
19 |
--------------------------------------------------------------------------------
/html/edit-post.html:
--------------------------------------------------------------------------------
1 | {{ template "head" . }}
2 |
3 | {{ if .IsOP }} Thread preview {{ else }} {{ "PostEdit" | translate }} {{ end }}
4 |
5 | {{ if .IsOP }}
6 | {{.Data.ThreadTitle }}
7 | {{ end }}
8 | {{.Data.Content | markup }}
9 |
10 |
25 |
26 | {{ template "footer" . }}
27 |
--------------------------------------------------------------------------------
/html/footer.html:
--------------------------------------------------------------------------------
1 | {{ define "footer" }}
2 |