├── .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 | 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 | 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 | ![my alt text](https://...) 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 | 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: ![description](url). 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 |
15 |
16 | 17 | 18 |
19 | 20 | 21 | 22 |
23 | 24 |
25 |
26 | 27 |
28 |
29 |
30 | 31 |
32 |

Change username

33 |
34 |
35 | 36 | 37 |
38 |
39 | 40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 |
51 | 52 |
53 |

Delete account

54 |
55 |

Choosing this action will delete your account. Decide below how you want account deletion to affect the posts 56 | you have made.

57 | Note 1: mentions of your username made by others in their posts are not currently edited as a result 58 | of your account deletion. 59 | Note 2: database backups that have yet to be replaced by newer backups or copies made of public threads by 60 | external web scrapers will not be affected by your account deletion. 61 |
62 | Decide on your posting legacy 63 |

64 | Deleting your account means deleting its details from the database so that it can't be used again. 65 | However, below you can decide on the granularity of your decision. 66 |

67 |
68 | 69 | 70 |
71 | 72 |
If the account removal approach above isn't desirable, choose one of the options below instead 73 |
74 | 75 | 77 |
78 |
79 | 80 | 82 |
83 |
84 | 85 | 87 |
88 |
89 | 90 | 92 |

Note: all options (other than 'None') result in the closing of your account.

93 |
94 |
95 |
96 |
97 | 98 | 99 |
100 | 101 | 102 |
103 | 104 |
105 |
106 |
107 | 108 |
109 | {{ template "footer" . }} 110 | -------------------------------------------------------------------------------- /html/admin-add-user.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |
3 |

{{ .Title }}

4 |

{{ "AdminAddUserExplanation" | translate }}

5 | 6 |
7 | 8 | 9 |
10 | 11 |
12 |
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 |
19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 |
27 |
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 |
42 | 43 |
44 |

ID for this batch: {{ $batch.BatchId }}

45 |

Delete remaining invites in this batch

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 |
8 | 9 |
10 |

11 | Do you want to view or create invites? . 12 |

13 |

14 | {{ "AdminAddNewUserQuestion" | translate }} . 15 |

16 |

17 | {{ "AdminStepDownExplanation" | 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 | 35 | 36 | 37 | 38 | 42 | 43 | {{ end }} 44 |
{{ $user.Name }} ({{ $user.ID }}) 39 | {{ if eq $userID $user.ID }} ({{ "AdminYou" | translate }}) 40 | {{ else }}{{ end }} 41 |
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 | 56 | 57 | 58 | {{ range $index, $proposal := .Data.Proposals }} 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | {{ $selfProposal := eq $userID $proposal.ProposerID }} 70 | 71 | 72 | {{ end }} 73 |
{{ "Proposal" | translate }}{{ "AdminSelfProposalsBecomeValid" | translate }}
{{ $proposal.Action | tohtml }} {{ $proposal.Time | formatDateTime }}
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 | 85 | 86 | 87 | 88 | {{ range $index, $reggedInvite := .Data.Registrations }} 89 | 90 | 91 | 92 | 93 | 94 | {{ end }} 95 |
LabelUsesID
{{ if len $reggedInvite.Label | eq 0 }} Unlabeled batch {{ else }} {{ $reggedInvite.Label }} {{ end }}{{ $reggedInvite.Count }}
Batch ID{{ $reggedInvite.BatchID }}
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 | 107 | 108 | 109 | 110 | 117 | 118 | 121 | 122 | {{ end }} 123 | 124 | {{ end }} 125 |
{{ $user.Name }} ({{ $user.ID }}) 111 | 116 |
invite/register info{{ $user.RegistrationOrigin }}
119 | 120 |
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 |
5 |
6 | 7 | 8 |
9 |
10 | 11 | 12 |
{{ "PasswordMin" | translate }}.
13 |
14 |
15 | 16 |
17 |
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 |
11 |
12 | {{ if .IsOP }} 13 | 14 | 15 | 16 | {{ end }} 17 | 18 | 19 | 20 |
21 |
22 | {{ "GoBackToTheThread" | translate }} 23 |
24 |
25 |
26 | {{ template "footer" . }} 27 | -------------------------------------------------------------------------------- /html/footer.html: -------------------------------------------------------------------------------- 1 | {{ define "footer" }} 2 | 3 | 4 | {{ end }} 5 | -------------------------------------------------------------------------------- /html/generic-message.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |

{{ .Data.Title }}

3 |

{{ .Data.Message }}

4 | {{ if .Data.Link }} 5 |

{{ .Data.LinkMessage }} {{.Data.LinkText}}.

6 | {{ end }} 7 | -------------------------------------------------------------------------------- /html/head.html: -------------------------------------------------------------------------------- 1 | {{ define "head" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ .ForumName }} — {{ .Title }} 9 | 10 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 |
210 | 211 | {{ dumpLogo }} 212 | 213 | 261 |
262 | 263 | 264 | {{ end }} 265 | -------------------------------------------------------------------------------- /html/html.go: -------------------------------------------------------------------------------- 1 | package html 2 | 3 | import "embed" 4 | 5 | // Templates contain the raw HTML of all of our templates. 6 | // 7 | //go:embed *.html 8 | var Templates embed.FS 9 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |
3 | {{ if len .Data.Threads | eq 0 }} 4 |

{{ "ThreadsViewEmpty" | translate }}

5 | {{ else if len .Data.Categories | lt 1 }} 6 |
7 | filter threads (showing {{ len .Data.VisibleCategoriesMap }} of {{ len .Data.Categories 8 | }} categories) 9 |
10 |
11 | {{ $categoryMap := index .Data.VisibleCategoriesMap }} 12 | {{ range $index, $category := .Data.Categories}} 13 | {{ $showCategory := index $categoryMap $category }} 14 | 15 | 16 | 17 | 18 | 19 | {{ end }} 20 |
21 | 22 | 23 |
24 | {{ end }} 25 | {{ range $index, $thread := .Data.Threads }} 26 | {{ if $thread.Show }} 27 |

28 | {{ $thread.Title }} 29 | {{ if $thread.Private }} {{ end }} 30 |

31 | {{ end }} 32 | {{ end }} 33 |
34 | {{ if .LoggedIn }} 35 | 38 | {{ end }} 39 | {{ template "footer" . }} 40 | -------------------------------------------------------------------------------- /html/login-component.html: -------------------------------------------------------------------------------- 1 | {{ define "login-component" }} 2 |
3 |
4 |
5 | 6 | 7 |
8 |
9 | 10 | 11 |
{{ "PasswordMin" | translate }}
12 |
13 | 14 |
15 |
16 | {{ end }} 17 | -------------------------------------------------------------------------------- /html/login.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |
3 |

{{ "Login" | translate | capitalize }}

4 |

{{ "ForumDescription" | translateWithData | tohtml }} {{ "LoginNoAccount" | translate | tohtml }}

5 |
6 | {{ template "login-component" . }} 7 |

{{ "PasswordForgot" | translate }}

8 |
9 | {{ if .Data.FailedAttempt }} 10 |

{{ "LoginFailure" | translate | tohtml }}

11 | {{ else if .LoggedIn }} 12 |

{{ "LoginAlreadyLoggedIn" | translate | tohtml }}

13 | {{ end }} 14 |

15 |
16 | {{ template "footer" . }} 17 | -------------------------------------------------------------------------------- /html/moderation-log.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |
3 |

{{ .Title }}

4 | {{ if .LoggedIn }} 5 |
6 | {{ if len .Data.Log | eq 100 }} 7 |

{{ "ModLogNoActions" | translate }}

8 | {{ else }} 9 |

{{ "ModLogExplanation" | translate }} {{ if .IsAdmin }} {{ "ModLogExplanationAdmin" | translate }} {{ end }}

10 | 21 |
    22 | {{ range $index, $entry := .Data.Log }} 23 |
  • {{ $entry | tohtml }}
  • 24 | {{ end }} 25 |
26 |
27 | {{ end }} 28 | {{ else }} 29 |

{{ "ModLogOnlyLoggedInMayView" | translate }}

30 | {{ end }} 31 |
32 | {{ template "footer" . }} 33 | -------------------------------------------------------------------------------- /html/new-thread.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |
3 |

{{ "ThreadCreate" | translate }}

4 |
5 |
6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 |
14 | 15 |
16 |
17 |
18 | {{ template "footer" . }} 19 | -------------------------------------------------------------------------------- /html/password-reset.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |

This page is currently being reconstructed; if you can't log in, contact admin for help to reset your password :)

3 |

{{ "PasswordResetDescription" | translate }}

4 |

{{ "PasswordResetUsernameQuestion" | translate }}

5 | {{ if eq .Data.Action "/reset/generate" }} 6 |
7 | 8 | 9 |
10 |
11 |
12 | {{ end }} 13 | 14 | 15 | {{ if eq .Data.Action "/reset/submit" }} 16 | 17 |

{{ "PasswordResetCopyPayload" | translate | tohtml }}:

18 |
19 | {{ .Data.Payload }}
20 | 
21 | 22 | {{ end }} 23 | {{ template "footer" . }} 24 | -------------------------------------------------------------------------------- /html/register-success.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |
3 |

{{ "Register" | translate | capitalize }}

4 |

{{ "RegisterHTMLMessage" | translate | tohtml }}

5 |
6 | 7 | {{ template "footer" . }} 8 | -------------------------------------------------------------------------------- /html/register.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |
3 |

{{ "Register" | translate | capitalize }}

4 | {{ .Data.Rules }} 5 |
6 | {{ "RegisterInviteInstructionsTitle" | translate }} 7 | 8 | {{ .Data.InviteInstructions }} 9 |
10 | 11 |
12 | 13 | 14 | 15 | 16 |
{{ "PasswordMin" | translate }}.
17 | 18 | 19 | {{ if ne .Data.ConductLink "" }} 20 |
21 |
22 | 23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 | {{ end }} 31 |
32 | 33 |
34 |
35 | 36 | {{ if .Data.ErrorMessage }} 37 |
38 |

{{ .Data.ErrorMessage }}

39 |
40 | {{ end }} 41 | 42 |
43 | {{ template "footer" . }} 44 | -------------------------------------------------------------------------------- /html/thread.html: -------------------------------------------------------------------------------- 1 | {{ template "head" . }} 2 |
3 |

{{ .Data.Title }}

4 | {{ if .Data.Private }} 5 |

{{ "PostPrivate" | translate }}

6 | {{ end }} 7 | {{ $userID := .LoggedInID }} 8 | {{ $threadURL := .Data.ThreadURL }} 9 | {{ range $index, $post := .Data.Posts }} 10 |
11 |
12 | {{ if eq $post.AuthorID $userID }} 13 | 14 |
16 | 17 | 18 |
19 |
20 | {{ if eq $index 0 }} 21 | edit thread 22 | {{ else }} 23 | edit 24 | {{ end }} 25 | {{ end }} 26 | {{ "Author" | translate }}: 27 | {{ $post.Author }} 28 | {{ "Responded" | translate }}: 29 | 30 | 31 | 32 | 33 | {{ if $post.LastEdit.Valid }} 34 | 35 | 36 | 37 | {{ end }} 38 |
39 | {{ $post.Content | markup }} 40 |
41 | {{ end }} 42 | {{ if .LoggedIn }} 43 |
44 |
45 |
46 | 47 | 48 | 49 |
50 |
51 |
52 | {{ end }} 53 |
54 | {{ template "footer" . }} 55 | -------------------------------------------------------------------------------- /i18n/i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "cerca/util" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "strings" 9 | ) 10 | 11 | const toolURL = "https://github.com/cblgh/cerca/releases/tag/pwtool-v1" 12 | 13 | var English = map[string]string{ 14 | "About": "about", 15 | "Login": "login", 16 | "Logout": "logout", 17 | "Sort": "sort", 18 | "Enter": "enter", 19 | "Register": "register", 20 | "Bottom": "bottom", 21 | 22 | "LoggedIn": "logged in", 23 | "NotLoggedIn": "Not logged in", 24 | "LogIn": "log in", 25 | "GoBack": "Go back", 26 | 27 | "SortRecentPosts": "recent posts", 28 | "SortRecentThreads": "recent threads", 29 | 30 | "modlogCreateInvites": `{{ .Data.Time }} {{ .Data.ActingUsername }} created a batch of invites`, 31 | "modlogDeleteInvites": `{{ .Data.Time }} {{ .Data.ActingUsername }} deleted a batch of invites`, 32 | "modlogResetPassword": `{{ .Data.Time }} {{ .Data.ActingUsername }} reset a user's password`, 33 | "modlogResetPasswordAdmin": `{{ .Data.Time }} {{ .Data.ActingUsername }} reset {{ .Data.RecipientUsername}}'s password`, 34 | "modlogRemoveUser": `{{ .Data.Time }} {{ .Data.ActingUsername }} removed a user's account`, 35 | "modlogMakeAdmin": `{{ .Data.Time }} {{ .Data.ActingUsername }} made {{ .Data.RecipientUsername}} an admin`, 36 | "modlogAddUser": `{{ .Data.Time }} {{ .Data.ActingUsername }} manually registered an account for a new user`, 37 | "modlogAddUserAdmin": `{{ .Data.Time }} {{ .Data.ActingUsername }} manually registered an account for {{ .Data.RecipientUsername }}`, 38 | "modlogDemoteAdmin": `{{ .Data.Time }} {{ .Data.ActingUsername }} demoted 39 | {{ if eq .Data.ActingUsername .Data.RecipientUsername }} themselves 40 | {{ else }} {{ .Data.RecipientUsername}} {{ end }} from admin back to normal user`, 41 | "modlogXProposedY": `{{ .Data.Time }} {{ .Data.ActingUsername }} proposed: {{ .Data.Action }}`, 42 | "modlogProposalMakeAdmin": `Make {{ .Data.RecipientUsername}} admin`, 43 | "modlogProposalDemoteAdmin": `Demote {{ .Data.RecipientUsername}} from role admin`, 44 | "modlogProposalRemoveUser": `Remove user {{ .Data.RecipientUsername }} `, 45 | "modlogConfirm": "{{ .Data.Action }} confirmed by {{ .Data.ActingUsername }}", 46 | "modlogVeto": "{{ .Data.Action }} vetoed by {{ .Data.ActingUsername }}", 47 | 48 | "Admins": "admins", 49 | "AdminVeto": "Veto", 50 | "AdminConfirm": "Confirm", 51 | "AdminForumAdministration": "Forum Administration", 52 | "AdminYou": "you!", 53 | "AdminUsers": "Users", 54 | "AdminNoAdmins": "There are no admins", 55 | "AdminNoUsers": "There are no other users", 56 | "AdminNoPendingProposals": "There are no pending proposals", 57 | "AdminAddNewUser": "Add new user", 58 | "AdminAddNewUserQuestion": "Does someone wish attendence? You can ", 59 | "AdminStepDown": "Step down", 60 | "AdminStepDownExplanation": "If you want to stop being an admin, you can", 61 | "AdminViewPastActions": "View past actions in the", 62 | "ModerationLog": "moderation log", 63 | "AdminDemote": "Demote", 64 | "DeletedUser": "deleted user", 65 | "RemoveAccount": "remove account", 66 | "AdminMakeAdmin": "Make admin", 67 | "Submit": "Submit", 68 | "AdminSelfConfirmationsHover": "a week must pass before self-confirmations are ok", 69 | "Proposal": "Proposal", 70 | "PendingProposals": "Pending Proposals", 71 | "AdminSelfProposalsBecomeValid": "Date self-proposals become valid", 72 | "AdminPendingExplanation": `Two admins are required for making a user an admin, demoting an existing 73 | admin, or removing a user. The first proposes the action, the second confirms 74 | (or vetos) it. If enough time elapses without a veto, the proposer may confirm their own 75 | proposal.`, 76 | 77 | "AdminAddUserExplanation": "Register a new user account. After registering the account you will be given a generated password and instructions to pass onto the user.", 78 | "AdminForumHasAdmins": "The forum currently has the following admins", 79 | "AdminOnlyLoggedInMayView": "Only logged in users may view the forum's admins.", 80 | "AdminPasswordSuccessInstructions": `Instructions: %s's password was set to: %s. After logging in, please change your password by going to /reset`, 81 | 82 | "ModLogNoActions": "there are no logged moderation actions", 83 | "ModLogExplanation": `This resource lists the moderation actions taken by the forum's administrators.`, 84 | "ModLogExplanationAdmin": `You are viewing this page as an admin, you will see slightly more details.`, 85 | "ModLogOnlyLoggedInMayView": "Only logged in users may view the moderation log.", 86 | 87 | "LoginNoAccount": "Don't have an account yet? Register one.", 88 | "LoginFailure": "Failed login attempt: incorrect password, wrong username, or a non-existent user.", 89 | "LoginAlreadyLoggedIn": `You are already logged in. Would you like to log out?`, 90 | 91 | "Username": "username", 92 | "Current": "current", 93 | "New": "new", 94 | "ChangePasswordDescription": "Use this page to change your password.", 95 | "Password": "password", 96 | "PasswordMin": "Must be at least 9 characters long", 97 | "PasswordForgot": "Forgot your password?", 98 | 99 | "Posts": "posts", 100 | "Threads": "threads", 101 | "ThreadNew": "new thread", 102 | "ThreadThe": "the thread", 103 | "Index": "index", 104 | "GoBackToTheThread": "Go back to the thread", 105 | "ThreadsViewEmpty": "There are currently no threads.", 106 | 107 | "ThreadCreate": "Create thread", 108 | "Title": "Title", 109 | "Content": "Content", 110 | "Private": "Private", 111 | "Create": "Create", 112 | "TextareaPlaceholder": "Tabula rasa", 113 | 114 | "PasswordReset": "reset password", 115 | "PasswordResetSuccess": "Reset password—success!", 116 | "PasswordResetSuccessMessage": "You reset your password!", 117 | "PasswordResetSuccessLinkMessage": "Give it a try and", 118 | 119 | "RegisterMessage": "You already have an account (you are logged in with it).", 120 | "RegisterLinkMessage": "Visit the", 121 | "RegisterSuccess": "registered successfully", 122 | 123 | "ErrUnaccepted": "Unaccepted request", 124 | "ErrGeneric401": "Unauthorized", 125 | "ErrGeneric401Message": "You do not have permissions to perform this action.", 126 | "ErrEdit404": "Post not found", 127 | "ErrEdit404Message": "This post cannot be found for editing", 128 | "ErrThread404": "Thread not found", 129 | "ErrThread404Message": "The thread does not exist (anymore?)", 130 | "ErrGeneric404": "Page not found", 131 | "ErrGeneric404Message": "The visited page does not exist (anymore?). Error code %d.", 132 | 133 | "NewThreadMessage": "Only members of this forum may create new threads", 134 | "NewThreadLinkMessage": "If you are a member,", 135 | "NewThreadCreateError": "Error creating thread", 136 | "NewThreadCreateErrorMessage": "There was a database error when creating the thread, apologies.", 137 | "PostEdit": "Post preview", 138 | "PostPrivate": "This is a private thread, only logged-in users can see it and read its posts.", 139 | 140 | "AriaPostMeta": "Post meta", 141 | "AriaDeletePost": "Delete this post", 142 | "AriaRespondIntoThread": "Respond into this thread", 143 | "PromptDeleteQuestion": "Delete post for all posterity?", 144 | "Delete": "delete", 145 | "Edit": "edit", 146 | "EditedAt": "edited at", 147 | "Post": "post", 148 | "Save": "Save", 149 | "Author": "Author", 150 | "Responded": "responded", 151 | "YourAnswer": "Your answer", 152 | 153 | "AriaHome": "Home", 154 | "ThreadStartNew": "Start a new thread", 155 | 156 | "RegisterHTMLMessage": `You now have an account! Welcome. Visit the index to read and reply to threads, or start a new one.`, 157 | 158 | "RegisterInviteInstructionsTitle": "How to get an invite code", 159 | "RegisterConductCodeBoxOne": `I have refreshed my memory of the {{ .Data.Name }} Code of Conduct`, 160 | "RegisterConductCodeBoxTwo": `Yes, I have actually read it`, 161 | 162 | "NewPassword": "new password", 163 | "ChangePassword": "change password", 164 | } 165 | 166 | var Swedish = map[string]string{ 167 | "About": "om", 168 | "Login": "logga in", 169 | "Logout": "logga ut", 170 | "Sort": "sortera", 171 | "Enter": "skicka", 172 | "Register": "registrera", 173 | "Bottom": "hoppa ner", 174 | 175 | "LoggedIn": "inloggad", 176 | "NotLoggedIn": "Ej inloggad", 177 | "LogIn": "logga in", 178 | "GoBack": "Go back", 179 | 180 | "SortRecentPosts": "nyast poster", 181 | "SortRecentThreads": "nyast trådar", 182 | 183 | /* begin 2025-03-26: to translate to swedish */ 184 | "modlogCreateInvites": `{{ .Data.Time }} {{ .Data.ActingUsername }} created a batch of invites`, 185 | "modlogDeleteInvites": `{{ .Data.Time }} {{ .Data.ActingUsername }} deleted a batch of invites`, 186 | "modlogResetPassword": `{{ .Data.Time }} {{ .Data.ActingUsername }} reset a user's password`, 187 | "modlogResetPasswordAdmin": `{{ .Data.Time }} {{ .Data.ActingUsername }} reset {{ .Data.RecipientUsername}}'s password`, 188 | "modlogRemoveUser": `{{ .Data.Time }} {{ .Data.ActingUsername }} removed a user's account`, 189 | "modlogMakeAdmin": `{{ .Data.Time }} {{ .Data.ActingUsername }} made {{ .Data.RecipientUsername}} an admin`, 190 | "modlogAddUser": `{{ .Data.Time }} {{ .Data.ActingUsername }} manually registered an account for a new user`, 191 | "modlogAddUserAdmin": `{{ .Data.Time }} {{ .Data.ActingUsername }} manually registered an account for {{ .Data.RecipientUsername }}`, 192 | "modlogDemoteAdmin": `{{ .Data.Time }} {{ .Data.ActingUsername }} demoted 193 | {{ if eq .Data.ActingUsername .Data.RecipientUsername }} themselves 194 | {{ else }} {{ .Data.RecipientUsername}} {{ end }} from admin back to normal user`, 195 | "modlogXProposedY": `{{ .Data.Time }} {{ .Data.ActingUsername }} proposed: {{ .Data.Action }}`, 196 | "modlogProposalMakeAdmin": `Make {{ .Data.RecipientUsername}} admin`, 197 | "modlogProposalDemoteAdmin": `Demote {{ .Data.RecipientUsername}} from role admin`, 198 | "modlogProposalRemoveUser": `Remove user {{ .Data.RecipientUsername }} `, 199 | "modlogConfirm": "{{ .Data.Action }} confirmed by {{ .Data.ActingUsername }}", 200 | "modlogVeto": "{{ .Data.Action }} vetoed by {{ .Data.ActingUsername }}", 201 | 202 | "Admins": "admins", 203 | "AdminVeto": "Veto", 204 | "AdminConfirm": "Confirm", 205 | "AdminForumAdministration": "Forum Administration", 206 | "AdminYou": "you!", 207 | "AdminUsers": "Users", 208 | "AdminNoAdmins": "There are no admins", 209 | "AdminNoUsers": "There are no other users", 210 | "AdminNoPendingProposals": "There are no pending proposals", 211 | "AdminAddNewUser": "Add new user", 212 | "AdminAddNewUserQuestion": "Does someone wish attendence? You can ", 213 | "AdminStepDown": "Step down", 214 | "AdminStepDownExplanation": "If you want to stop being an admin, you can", 215 | "AdminViewPastActions": "View past actions in the", 216 | "ModerationLog": "moderation log", 217 | "AdminDemote": "Demote", 218 | "DeletedUser": "deleted user", 219 | "RemoveAccount": "remove account", 220 | "AdminMakeAdmin": "Make admin", 221 | "Submit": "Submit", 222 | "AdminSelfConfirmationsHover": "a week must pass before self-confirmations are ok", 223 | "Proposal": "Proposal", 224 | "PendingProposals": "Pending Proposals", 225 | "AdminSelfProposalsBecomeValid": "Date self-proposals become valid", 226 | "AdminPendingExplanation": `Two admins are required for making a user an admin, demoting an existing 227 | admin, or removing a user. The first proposes the action, the second confirms 228 | (or vetos) it. If enough time elapses without a veto, the proposer may confirm their own 229 | proposal.`, 230 | 231 | "AdminAddUserExplanation": "Register a new user account. After registering the account you will be given a generated password and instructions to pass onto the user.", 232 | "AdminForumHasAdmins": "The forum currently has the following admins", 233 | "AdminOnlyLoggedInMayView": "Only logged in users may view the forum's admins.", 234 | "AdminPasswordSuccessInstructions": `Instructions: %s's password was set to: %s. After logging in, please change your password by going to /reset`, 235 | 236 | "ModLogNoActions": "there are no logged moderation actions", 237 | "ModLogExplanation": `This resource lists the moderation actions taken by the forum's administrators.`, 238 | "ModLogExplanationAdmin": `You are viewing this page as an admin, you will see slightly more details.`, 239 | "ModLogOnlyLoggedInMayView": "Only logged in users may view the moderation log.", 240 | 241 | /* end 2025-03-26: to translate to swedish */ 242 | 243 | "LoginNoAccount": "Saknar du konto? Skapa ett.", 244 | "LoginFailure": "Misslyckat inloggningsförsök: inkorrekt lösenord, fel användernamn, eller obefintlig användare.", 245 | "LoginAlreadyLoggedIn": `Du är redan inloggad. Vill du logga ut?`, 246 | 247 | "Username": "användarnamn", 248 | "Current": "nuvarande", 249 | "New": "nytt", 250 | "ChangePasswordDescription": "På den här sidan kan du ändra ditt lösenord.", 251 | "Password": "lösenord", 252 | "PasswordMin": "Måste vara minst 9 karaktärer långt", 253 | "PasswordForgot": "Glömt lösenordet?", 254 | 255 | "Posts": "poster", 256 | "Threads": "trådar", 257 | "ThreadNew": "ny tråd", 258 | "ThreadThe": "tråden", 259 | "Index": "index", 260 | "GoBackToTheThread": "Gå tillbaka till tråden", 261 | "ThreadsViewEmpty": "Det finns för närvarande inga trådar", 262 | 263 | "ThreadCreate": "Skapa en tråd", 264 | "Title": "Titel", 265 | "Content": "Innehåll", 266 | "Create": "Skapa", 267 | "Private": "Privat", 268 | "TextareaPlaceholder": "Tabula rasa", 269 | 270 | "PasswordReset": "nollställ lösenord", 271 | "PasswordResetSuccess": "Nollställning av lösenord—lyckades!", 272 | "PasswordResetSuccessMessage": "Du har nollställt ditt lösenord!", 273 | "PasswordResetSuccessLinkMessage": "Ge det ett försök och", 274 | 275 | "RegisterMessage": "Du har redan ett konto (du är inloggad med det).", 276 | "RegisterLinkMessage": "Besök", 277 | "RegisterSuccess": "konto skapat", 278 | 279 | "ErrUnaccepted": "Ej accepterat request", 280 | "ErrThread404": "Tråd ej funnen", 281 | "ErrThread404Message": "Denna tråden finns ej (längre?)", 282 | "ErrGeneric404": "Sida ej funnen", 283 | "ErrGeneric404Message": "Den besökta sidan finns ej (längre?). Felkod %d.", 284 | 285 | "NewThreadMessage": "Enbart medlemmarna av detta forum får skapa nya trådar", 286 | "NewThreadLinkMessage": "Om du är en medlem,", 287 | "NewThreadCreateError": "Fel uppstod vid trådskapning", 288 | "NewThreadCreateErrorMessage": "Det uppstod ett databasfel under trådskapningen, ursäkta.", 289 | "PostEdit": "Post preview", 290 | 291 | "AriaPostMeta": "Post meta", 292 | "AriaDeletePost": "Delete this post", 293 | "AriaRespondIntoThread": "Respond into this thread", 294 | "PromptDeleteQuestion": "Radera post för alltid?", 295 | "Delete": "radera", 296 | "Edit": "redigera", 297 | "EditedAt": "redigerat", 298 | "Post": "post", 299 | "Author": "Författare", 300 | "Responded": "svarade", 301 | "YourAnswer": "Ditt svar", 302 | 303 | "AriaHome": "Hem", 304 | "ThreadStartNew": "Starta ny tråd", 305 | 306 | "RegisterHTMLMessage": `Du har nu ett konto! Välkommen. Besök trådindexet för att läsa och svara på trådar, eller för att starta en ny.`, 307 | 308 | "RegisterInviteInstructionsTitle": "Instruktioner för invitationskod", 309 | "RegisterConductCodeBoxOne": `I have refreshed my memory of the {{ .Data.Name }} Code of Conduct`, 310 | "RegisterConductCodeBoxTwo": `Yes, I have actually read it`, 311 | 312 | "PasswordResetDescription": "På denna sida går vi igenom ett par steg för att säkert nollställa ditt lösenord—utan att behöva ta till mejl!", 313 | "PasswordResetUsernameQuestion": "För de första: hur löd användarnamnet?", 314 | "NewPassword": "nytt lösenord", 315 | "ChangePassword": "ändra lösenord", 316 | } 317 | 318 | var EspanolMexicano = map[string]string{ 319 | "About": "acerca de", 320 | "Login": "loguearse", 321 | "Logout": "logout", 322 | "Sort": "sort", 323 | "Register": "register", 324 | "Enter": "entrar", 325 | "Bottom": "bottom", 326 | 327 | "LoggedIn": "logged in", 328 | "NotLoggedIn": "Not logged in", 329 | "LogIn": "log in", 330 | "GoBack": "Go back", 331 | 332 | "SortRecentPosts": "recent posts", 333 | "SortRecentThreads": "most recent threads", 334 | 335 | "LoginNoAccount": "¿No tienes una cuenta? Registra una. ", 336 | "LoginFailure": "Failed login attempt: incorrect password, wrong username, or a non-existent user.", 337 | "LoginAlreadyLoggedIn": `You are already logged in. Would you like to log out?`, 338 | 339 | "Username": "usuarie", 340 | "Current": "current", 341 | "New": "new", 342 | "ChangePasswordDescription": "Use this page to change your password.", 343 | "Password": "contraseña", 344 | "PasswordMin": "Debe tener por lo menos 9 caracteres.", 345 | "PasswordForgot": "Olvidaste tu contraseña?", 346 | 347 | "Posts": "posts", 348 | "Threads": "threads", 349 | "ThreadNew": "new thread", 350 | "ThreadThe": "the thread", 351 | "Index": "index", 352 | "GoBackToTheThread": "Go back to the thread", 353 | 354 | "ThreadCreate": "Create thread", 355 | "Title": "Title", 356 | "Content": "Content", 357 | "Create": "Create", 358 | "TextareaPlaceholder": "Tabula rasa", 359 | 360 | "PasswordReset": "reset password", 361 | "PasswordResetSuccess": "Reset password—success!", 362 | "PasswordResetSuccessMessage": "You reset your password!", 363 | "PasswordResetSuccessLinkMessage": "Give it a try and", 364 | 365 | "RegisterMessage": "You already have an account (you are logged in with it).", 366 | "RegisterLinkMessage": "Visit the", 367 | "RegisterSuccess": "registered successfully", 368 | 369 | "ErrUnaccepted": "Unaccepted request", 370 | "ErrThread404": "Thread not found", 371 | "ErrThread404Message": "The thread does not exist (anymore?)", 372 | "ErrGeneric404": "Page not found", 373 | "ErrGeneric404Message": "The visited page does not exist (anymore?). Error code %d.", 374 | 375 | "NewThreadMessage": "Only members of this forum may create new threads", 376 | "NewThreadLinkMessage": "If you are a member,", 377 | "NewThreadCreateError": "Error creating thread", 378 | "NewThreadCreateErrorMessage": "There was a database error when creating the thread, apologies.", 379 | "PostEdit": "Post preview", 380 | "ThreadStartNew": "Start a new thread", 381 | 382 | "AriaPostMeta": "Post meta", 383 | "AriaDeletePost": "Delete this post", 384 | "AriaRespondIntoThread": "Respond into this thread", 385 | "AriaHome": "Home", 386 | "PromptDeleteQuestion": "Delete post for all posterity?", 387 | "Delete": "delete", 388 | "Edit": "editar", 389 | "EditedAt": "editado a las", 390 | "Post": "post", 391 | "Save": "Save", 392 | "Author": "Author", 393 | "Responded": "responded", 394 | "YourAnswer": "Your answer", 395 | 396 | "RegisterHTMLMessage": `You now have an account! Welcome. Visit the index to read and reply to threads, or start a new one.`, 397 | 398 | "RegisterInviteInstructionsTitle": "How to get an invite code", 399 | "RegisterConductCodeBoxOne": `I have refreshed my memory of the {{ .Data.Name }} Code of Conduct`, 400 | "RegisterConductCodeBoxTwo": `Yes, I have actually read it`, 401 | 402 | "PasswordResetDescription": "On this page we'll go through a few steps to securely reset your password—without resorting to any emails!", 403 | "PasswordResetUsernameQuestion": "First up: what was your username?", 404 | "NewPassword": "new password", 405 | "ChangePassword": "change password", 406 | } 407 | 408 | var translations = map[string]map[string]string{ 409 | "English": English, 410 | "EspañolMexicano": EspanolMexicano, 411 | "Swedish": Swedish, 412 | } 413 | 414 | type TranslationData struct { 415 | Data interface{} 416 | } 417 | 418 | func (tr *Translator) TranslateWithData(key string, data TranslationData) string { 419 | phrase := translations[tr.Language][key] 420 | t, err := template.New(key).Parse(phrase) 421 | ed := util.Describe("i18n translation") 422 | ed.Check(err, "parse translation phrase") 423 | sb := new(strings.Builder) 424 | err = t.Execute(sb, data) 425 | ed.Check(err, "execute template with data") 426 | return sb.String() 427 | } 428 | 429 | func (tr *Translator) Translate(key string) string { 430 | var empty TranslationData 431 | return tr.TranslateWithData(key, empty) 432 | } 433 | 434 | type Translator struct { 435 | Language string 436 | } 437 | 438 | func Init(lang string) Translator { 439 | if _, ok := translations[lang]; !ok { 440 | log.Fatalln(fmt.Sprintf("language '%s' is not translated yet", lang)) 441 | } 442 | return Translator{lang} 443 | } 444 | 445 | // usage: 446 | // tr := Init("EnglishSwedish") 447 | // fmt.Println(tr.Translate("LoginNoAccount")) 448 | // fmt.Println(tr.TranslateWithData("LoginDescription", Community{"Merveilles", "https://merveill.es"})) 449 | -------------------------------------------------------------------------------- /limiter/limiter.go: -------------------------------------------------------------------------------- 1 | package limiter 2 | 3 | import ( 4 | "context" 5 | "golang.org/x/time/rate" 6 | "time" 7 | ) 8 | 9 | type TimedRateLimiter struct { 10 | // periodic forgetting of identifiers that have been seen & assigned a rate limiter to prevent bloat over time 11 | timers map[string]*time.Timer 12 | // buckets of access tokens, refreshing over time 13 | limiters map[string]*rate.Limiter 14 | // routes that are rate limited 15 | routes map[string]bool 16 | refreshPeriod time.Duration 17 | timeToRemember time.Duration 18 | burst int 19 | } 20 | 21 | func NewTimedRateLimiter(limitedRoutes []string, refresh, remember time.Duration) *TimedRateLimiter { 22 | rl := TimedRateLimiter{} 23 | rl.timers = make(map[string]*time.Timer) 24 | rl.limiters = make(map[string]*rate.Limiter) 25 | rl.routes = make(map[string]bool) 26 | for _, route := range limitedRoutes { 27 | rl.routes[route] = true 28 | } 29 | rl.refreshPeriod = refresh 30 | rl.timeToRemember = remember 31 | rl.burst = 15 /* default value, use rl.SetBurstAllowance to change */ 32 | return &rl 33 | } 34 | 35 | // amount of accesses allowed ~concurrently, before needing to wait for a rl.refreshPeriod 36 | func (rl *TimedRateLimiter) SetBurstAllowance(burst int) { 37 | if burst >= 1 { 38 | rl.burst = burst 39 | } 40 | } 41 | 42 | // find out if resource access is allowed or not: calling consumes a rate limit token 43 | func (rl *TimedRateLimiter) IsLimited(identifier, route string) bool { 44 | // route isn't rate limited 45 | if _, exists := rl.routes[route]; !exists { 46 | return false 47 | } 48 | // route is designated to be rate limited, try the limiter to see if we can access it 49 | ret := !rl.access(identifier) 50 | return ret 51 | } 52 | 53 | func (rl *TimedRateLimiter) BlockUntilAllowed(identifier, route string, ctx context.Context) error { 54 | // route isn't rate limited 55 | if _, exists := rl.routes[route]; !exists { 56 | return nil 57 | } 58 | limiter := rl.getLimiter(identifier) 59 | err := limiter.Wait(ctx) 60 | if err != nil { 61 | return err 62 | } 63 | return nil 64 | } 65 | 66 | func (rl *TimedRateLimiter) getLimiter(identifier string) *rate.Limiter { 67 | // limiter doesn't yet exist for this identifier 68 | if _, exists := rl.limiters[identifier]; !exists { 69 | // create a rate limit for it 70 | rl.createRateLimit(identifier) 71 | // remember this identifier (remote ip) for rl.timeToRemember before forgetting 72 | rl.rememberIdentifier(identifier) 73 | } 74 | limiter := rl.limiters[identifier] 75 | return limiter 76 | } 77 | 78 | // returns true if identifier currently allowed to access the resource 79 | func (rl *TimedRateLimiter) access(identifier string) bool { 80 | limiter := rl.getLimiter(identifier) 81 | // consumes one token from the rate limiter bucket 82 | allowed := limiter.Allow() 83 | return allowed 84 | } 85 | 86 | func (rl *TimedRateLimiter) createRateLimit(identifier string) { 87 | accessRate := rate.Every(rl.refreshPeriod) 88 | limit := rate.NewLimiter(accessRate, rl.burst) 89 | rl.limiters[identifier] = limit 90 | } 91 | 92 | func (rl *TimedRateLimiter) rememberIdentifier(identifier string) { 93 | // timer already exists; refresh it 94 | if timer, exists := rl.timers[identifier]; exists { 95 | timer.Reset(rl.timeToRemember) 96 | return 97 | } 98 | // new timer 99 | timer := time.AfterFunc(rl.timeToRemember, func() { 100 | rl.forgetLimiter(identifier) 101 | }) 102 | // map timer to its identifier 103 | rl.timers[identifier] = timer 104 | } 105 | 106 | // forget the rate limiter associated for this identifier (to prevent memory growth over time) 107 | func (rl *TimedRateLimiter) forgetLimiter(identifier string) { 108 | if _, exists := rl.limiters[identifier]; exists { 109 | delete(rl.limiters, identifier) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /server/account.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "cerca/crypto" 8 | "cerca/database" 9 | ) 10 | 11 | func renderMsgAccountView(h *RequestHandler, res http.ResponseWriter, req *http.Request, caller, errInput string) { 12 | errMessage := fmt.Sprintf("%s: %s", caller, errInput) 13 | loggedIn, userid := h.IsLoggedIn(req) 14 | username, _ := h.db.GetUsername(userid) 15 | h.renderView(res, "account", TemplateData{Data: AccountData{ErrorMessage: errMessage, LoggedInUsername: username, DeleteAccountRoute: ACCOUNT_DELETE_ROUTE, ChangeUsernameRoute: ACCOUNT_CHANGE_USERNAME_ROUTE, ChangePasswordRoute: ACCOUNT_CHANGE_PASSWORD_ROUTE}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: "Account"}) 16 | } 17 | 18 | func (h *RequestHandler) AccountChangePassword(res http.ResponseWriter, req *http.Request) { 19 | loggedIn, userid := h.IsLoggedIn(req) 20 | sectionTitle := "Change password" 21 | renderErr := func(errMsg string) { 22 | renderMsgAccountView(h, res, req, sectionTitle, errMsg) 23 | } 24 | // simple alias for the same thing, to make it less confusing in the different cases :) might be changed into some 25 | // other success behaviour at some future point 26 | renderSuccess := renderErr 27 | if req.Method == "GET" { 28 | if !loggedIn { 29 | IndexRedirect(res, req) 30 | return 31 | } 32 | http.Redirect(res, req, "/account", http.StatusSeeOther) 33 | return 34 | } else if req.Method == "POST" { 35 | // verify existing credentials 36 | currentPassword := req.PostFormValue("current-password") 37 | err := h.checkPasswordIsCorrect(userid, currentPassword) 38 | if err != nil { 39 | renderErr("Current password did not match up with the hash stored in database") 40 | return 41 | } 42 | 43 | newPassword := req.PostFormValue("new-password") 44 | newPasswordCopy := req.PostFormValue("new-password-copy") 45 | 46 | // too short 47 | if len(newPassword) < 9 { 48 | renderErr("New password is too short (needs to be at least 9 characters or longer)") 49 | return 50 | } 51 | // repeat password did not match 52 | if newPassword != newPasswordCopy { 53 | renderErr("New password was incorrectly repeated") 54 | return 55 | } 56 | // happy path 57 | if newPassword == newPasswordCopy { 58 | passwordHash, err := crypto.HashPassword(newPassword) 59 | if err != nil { 60 | renderErr("Critical failure - password hashing failed. Contact admin") 61 | } 62 | h.db.UpdateUserPasswordHash(userid, passwordHash) 63 | renderSuccess("Password has been updated!") 64 | } 65 | } 66 | } 67 | func (h *RequestHandler) AccountChangeUsername(res http.ResponseWriter, req *http.Request) { 68 | loggedIn, userid := h.IsLoggedIn(req) 69 | sectionTitle := "Change username" 70 | renderErr := func(errMsg string) { 71 | renderMsgAccountView(h, res, req, sectionTitle, errMsg) 72 | } 73 | renderSuccess := renderErr 74 | if req.Method == "GET" { 75 | if !loggedIn { 76 | IndexRedirect(res, req) 77 | return 78 | } 79 | http.Redirect(res, req, "/account", http.StatusSeeOther) 80 | return 81 | } else if req.Method == "POST" { 82 | // verify existing credentials 83 | currentPassword := req.PostFormValue("current-password") 84 | err := h.checkPasswordIsCorrect(userid, currentPassword) 85 | if err != nil { 86 | renderErr("Current password did not match up with the hash stored in database") 87 | return 88 | } 89 | newUsername := req.PostFormValue("new-username") 90 | var exists bool 91 | if exists, err = h.db.CheckUsernameExists(newUsername); err != nil { 92 | renderErr("Database had a problem when checking username") 93 | return 94 | } else if exists { 95 | renderErr(fmt.Sprintf("Username %s appears to already exist, please pick another name", newUsername)) 96 | return 97 | } 98 | h.db.UpdateUsername(userid, newUsername) 99 | renderSuccess(fmt.Sprintf("You are now known as %s", newUsername)) 100 | // TODO (2024-04-09): add modlog entry so that other forum users (or only admins?) can follow along with changing nicknames 101 | } 102 | } 103 | 104 | func (h *RequestHandler) AccountSelfServiceDelete(res http.ResponseWriter, req *http.Request) { 105 | loggedIn, userid := h.IsLoggedIn(req) 106 | sectionTitle := "Delete account" 107 | renderErr := func(errMsg string) { 108 | renderMsgAccountView(h, res, req, sectionTitle, errMsg) 109 | } 110 | if req.Method == "GET" { 111 | if !loggedIn { 112 | IndexRedirect(res, req) 113 | return 114 | } 115 | http.Redirect(res, req, "/account", http.StatusSeeOther) 116 | return 117 | } else if req.Method == "POST" { 118 | fmt.Printf("%s route hit with POST\n", ACCOUNT_DELETE_ROUTE) 119 | if !loggedIn { 120 | renderErr("You are, somehow, not logged in. Please refresh your browser, try to logout, and then log back in again.") 121 | return 122 | } 123 | // verify existing credentials 124 | currentPassword := req.PostFormValue("current-password") 125 | err := h.checkPasswordIsCorrect(userid, currentPassword) 126 | if err != nil { 127 | renderErr("Current password did not match up with the hash stored in database") 128 | return 129 | } 130 | 131 | /* since deletion is such a permanent action, we take some precautions with the following code and choose a verbose 132 | * but redundant approach to confirming the correctness of the received input */ 133 | deleteConfirmationCheckbox := req.PostFormValue("delete-confirm") 134 | if deleteConfirmationCheckbox != "on" { 135 | renderErr("The delete account confirmation checkbox was, somehow, not ticked.") 136 | return 137 | } 138 | 139 | delErrMsg := "[DERR%d] The delete account functionality hit an error, please ping the forum maintainer with this message and error code!" 140 | var deleteOpts database.RemoveUserOptions 141 | // contains values from a radio button 142 | deleteDecision := req.PostFormValue("delete-post-decision") 143 | // delete-everything is a checkbox: check if it was checked 144 | wantDeleteEverything := req.PostFormValue("delete-everything") == "on" 145 | // boolean to make sure that a delete option was accurately through either the delete-everything checkbox 146 | // or the granular options represented by radio buttons. 147 | // this check ensures that `deleteOpts` was actually set and doesn't just contain default values 148 | deleteIsConfigured := false 149 | 150 | // if delete everything and a granular option is chosen, error out instead 151 | if (len(deleteDecision) > 0 && deleteDecision != "no-choice") && wantDeleteEverything { 152 | renderErr("Choose either delete everything, or one of the more granular options; not both") 153 | return 154 | } 155 | // no option was chosen 156 | if (deleteDecision == "no-choice" || deleteDecision == "") && !wantDeleteEverything { 157 | renderErr("You did not choose a delete option; please try again and choose one of the account delete options.") 158 | return 159 | } 160 | 161 | if wantDeleteEverything { 162 | deleteOpts = database.RemoveUserOptions{KeepContent: false, KeepUsername: false} 163 | deleteIsConfigured = true 164 | } 165 | 166 | switch deleteDecision { 167 | case "posts-intact-username-intact": 168 | // Keep post contents and keep username attribution 169 | deleteOpts = database.RemoveUserOptions{KeepContent: true, KeepUsername: true} 170 | deleteIsConfigured = true 171 | case "posts-intact-username-removed": 172 | // Keep post contents but remove username from posts 173 | deleteOpts = database.RemoveUserOptions{KeepContent: true, KeepUsername: false} 174 | deleteIsConfigured = true 175 | case "posts-removed-username-intact": 176 | // Remove post contents and keep my username 177 | deleteOpts = database.RemoveUserOptions{KeepContent: false, KeepUsername: true} 178 | deleteIsConfigured = true 179 | case "no-choice": 180 | break 181 | default: 182 | renderErr(fmt.Sprintf(delErrMsg, 1)) 183 | fmt.Println("hit default for deleteDecision - not doing anything! this isn't good!!") 184 | return 185 | } 186 | 187 | if !deleteIsConfigured { 188 | renderErr(fmt.Sprintf(delErrMsg, 2)) 189 | fmt.Println("delete was not configured! this isn't good!!") 190 | return 191 | } 192 | 193 | // all our checks have passed and it looks like we're in for some deleting! 194 | fmt.Println("deleting user with userid", userid) 195 | err = h.db.RemoveUser(userid, deleteOpts) 196 | if err != nil { 197 | renderErr(fmt.Sprintf(delErrMsg, 3)) 198 | return 199 | } 200 | // log the user out 201 | http.Redirect(res, req, "/logout", http.StatusSeeOther) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /server/moderation.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | "cerca/constants" 11 | "cerca/crypto" 12 | "cerca/database" 13 | "cerca/i18n" 14 | "cerca/util" 15 | ) 16 | 17 | type AdminData struct { 18 | Admins []database.User 19 | Users []database.User 20 | Proposals []PendingProposal 21 | Registrations []database.RegisteredInvite 22 | IsAdmin bool 23 | } 24 | 25 | type ModerationData struct { 26 | Log []string 27 | } 28 | 29 | type PendingProposal struct { 30 | // ID is the id of the proposal 31 | ID, ProposerID int 32 | Action string 33 | Time time.Time // the time self-confirmations become possible for proposers 34 | TimePassed bool // self-confirmations valid or not 35 | } 36 | 37 | func (h RequestHandler) displayErr(res http.ResponseWriter, req *http.Request, err error, title string) { 38 | errMsg := util.Eout(err, fmt.Sprintf("%s failed", title)) 39 | fmt.Println(errMsg) 40 | data := GenericMessageData{ 41 | Title: title, 42 | Message: errMsg.Error(), 43 | } 44 | h.renderGenericMessage(res, req, data) 45 | } 46 | 47 | func (h RequestHandler) displaySuccess(res http.ResponseWriter, req *http.Request, title, message, backRoute string) { 48 | data := GenericMessageData{ 49 | Title: title, 50 | Message: message, 51 | LinkText: h.translator.Translate("GoBack"), 52 | Link: backRoute, 53 | } 54 | h.renderGenericMessage(res, req, data) 55 | } 56 | 57 | // TODO (2023-12-10): any vulns with this approach? could a user forge a session cookie with the user id of an admin? 58 | func (h RequestHandler) IsAdmin(req *http.Request) (bool, int) { 59 | ed := util.Describe("IsAdmin") 60 | userid, err := h.session.Get(req) 61 | err = ed.Eout(err, "getting userid from session cookie") 62 | if err != nil { 63 | dump(err) 64 | return false, -1 65 | } 66 | 67 | // make sure the user from the cookie actually exists 68 | userExists, err := h.db.CheckUserExists(userid) 69 | if err != nil { 70 | dump(ed.Eout(err, "check userid in db")) 71 | return false, -1 72 | } else if !userExists { 73 | return false, -1 74 | } 75 | // make sure the user id is actually an admin 76 | userIsAdmin, err := h.db.IsUserAdmin(userid) 77 | if err != nil { 78 | dump(ed.Eout(err, "IsUserAdmin in db")) 79 | return false, -1 80 | } else if !userIsAdmin { 81 | return false, -1 82 | } 83 | return true, userid 84 | } 85 | 86 | // there is a 2-quorum (requires 2 admins to take effect) imposed for the following actions, which are regarded as 87 | // consequential: 88 | // * make admin 89 | // * remove account 90 | // * demote admin 91 | 92 | // note: there is only a 2-quorum constraint imposed if there are actually 2 admins. an admin may also confirm their own 93 | // proposal if constants.PROPOSAL_SELF_CONFIRMATION_WAIT seconds have passed (1 week) 94 | func performQuorumCheck(ed util.ErrorDescriber, db *database.DB, adminUserId, targetUserId, proposedAction int) error { 95 | // checks if a quorum is necessary for the proposed action: if a quorum constarin is in effect, a proposal is created 96 | // otherwise (if no quorum threshold has been achieved) the action is taken directly 97 | quorumActivated := db.QuorumActivated() 98 | 99 | var err error 100 | var modlogErr error 101 | if quorumActivated { 102 | err = db.ProposeModerationAction(adminUserId, targetUserId, proposedAction) 103 | } else { 104 | switch proposedAction { 105 | case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: 106 | // TODO (2025-04-13): introduce granularity to admin delete view wrt these booleans 107 | err = db.RemoveUser(targetUserId, database.RemoveUserOptions{KeepContent: false, KeepUsername: false}) 108 | modlogErr = db.AddModerationLog(adminUserId, -1, constants.MODLOG_REMOVE_USER) 109 | case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: 110 | err = db.AddAdmin(targetUserId) 111 | modlogErr = db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_MAKE) 112 | case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: 113 | err = db.DemoteAdmin(targetUserId) 114 | modlogErr = db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_DEMOTE) 115 | } 116 | } 117 | if modlogErr != nil { 118 | fmt.Println(ed.Eout(modlogErr, "error adding moderation log")) 119 | } 120 | if err != nil { 121 | return err 122 | } 123 | return nil 124 | } 125 | 126 | func (h *RequestHandler) AdminRemoveUser(res http.ResponseWriter, req *http.Request, targetUserId int) { 127 | ed := util.Describe("Admin remove user") 128 | loggedIn, _ := h.IsLoggedIn(req) 129 | isAdmin, adminUserId := h.IsAdmin(req) 130 | 131 | if req.Method == "GET" || !loggedIn || !isAdmin { 132 | IndexRedirect(res, req) 133 | return 134 | } 135 | 136 | err := performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER) 137 | 138 | if err != nil { 139 | h.displayErr(res, req, err, "User removal") 140 | return 141 | } 142 | 143 | // success! redirect back to /admin 144 | http.Redirect(res, req, "/admin", http.StatusFound) 145 | } 146 | 147 | func (h *RequestHandler) AdminMakeUserAdmin(res http.ResponseWriter, req *http.Request, targetUserId int) { 148 | ed := util.Describe("make user admin") 149 | loggedIn, _ := h.IsLoggedIn(req) 150 | isAdmin, adminUserId := h.IsAdmin(req) 151 | if req.Method == "GET" || !loggedIn || !isAdmin { 152 | IndexRedirect(res, req) 153 | return 154 | } 155 | 156 | title := h.translator.Translate("AdminMakeAdmin") 157 | 158 | err := performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN) 159 | 160 | if err != nil { 161 | h.displayErr(res, req, err, title) 162 | return 163 | } 164 | 165 | if !h.db.QuorumActivated() { 166 | username, _ := h.db.GetUsername(targetUserId) 167 | message := fmt.Sprintf("User %s is now a fellow admin user!", username) 168 | h.displaySuccess(res, req, title, message, "/admin") 169 | } else { 170 | // redirect to admin view, which should have a proposal now 171 | http.Redirect(res, req, "/admin", http.StatusFound) 172 | } 173 | } 174 | 175 | func (h *RequestHandler) AdminDemoteAdmin(res http.ResponseWriter, req *http.Request) { 176 | ed := util.Describe("demote admin route") 177 | loggedIn, _ := h.IsLoggedIn(req) 178 | isAdmin, adminUserId := h.IsAdmin(req) 179 | 180 | if req.Method == "GET" || !loggedIn || !isAdmin { 181 | IndexRedirect(res, req) 182 | return 183 | } 184 | 185 | title := h.translator.Translate("AdminDemote") 186 | 187 | useridString := req.PostFormValue("userid") 188 | targetUserId, err := strconv.Atoi(useridString) 189 | util.Check(err, "convert user id string to a plain userid") 190 | 191 | err = performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN) 192 | 193 | if err != nil { 194 | h.displayErr(res, req, err, title) 195 | return 196 | } 197 | 198 | if !h.db.QuorumActivated() { 199 | username, _ := h.db.GetUsername(targetUserId) 200 | message := fmt.Sprintf("User %s is now a regular user", username) 201 | // output copy-pastable credentials page for admin to send to the user 202 | h.displaySuccess(res, req, title, message, "/admin") 203 | } else { 204 | http.Redirect(res, req, "/admin", http.StatusFound) 205 | } 206 | } 207 | 208 | func (h *RequestHandler) AdminManualAddUserRoute(res http.ResponseWriter, req *http.Request) { 209 | ed := util.Describe("admin manually add user") 210 | loggedIn, _ := h.IsLoggedIn(req) 211 | isAdmin, adminUserId := h.IsAdmin(req) 212 | 213 | if !isAdmin { 214 | IndexRedirect(res, req) 215 | return 216 | } 217 | 218 | type AddUser struct { 219 | ErrorMessage string 220 | } 221 | 222 | var data AddUser 223 | view := TemplateData{Title: h.translator.Translate("AdminAddNewUser"), Data: &data, HasRSS: h.config.RSS.URL != "", IsAdmin: isAdmin, LoggedIn: loggedIn} 224 | 225 | if req.Method == "GET" { 226 | h.renderView(res, "admin-add-user", view) 227 | return 228 | } 229 | 230 | if req.Method == "POST" && isAdmin { 231 | username := req.PostFormValue("username") 232 | 233 | // do a lil quick checky check to see if we already have that username registered, 234 | // and if we do re-render the page with an error 235 | existed, err := h.db.CheckUsernameExists(username) 236 | ed.Check(err, "check username exists") 237 | 238 | if existed { 239 | data.ErrorMessage = fmt.Sprintf("Username (%s) is already registered", username) 240 | h.renderView(res, "admin-add-user", view) 241 | return 242 | } 243 | 244 | // set up basic credentials 245 | newPassword := crypto.GeneratePassword() 246 | passwordHash, err := crypto.HashPassword(newPassword) 247 | ed.Check(err, "hash password") 248 | targetUserId, err := h.db.CreateUser(username, passwordHash) 249 | ed.Check(err, "create new user %s", username) 250 | 251 | err = h.db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_ADD_USER) 252 | if err != nil { 253 | fmt.Println(ed.Eout(err, "error adding moderation log")) 254 | } 255 | 256 | title := h.translator.Translate("AdminAddNewUser") 257 | message := fmt.Sprintf(h.translator.Translate("AdminPasswordSuccessInstructions"), template.HTMLEscapeString(username), newPassword) 258 | h.displaySuccess(res, req, title, message, "/add-user") 259 | } 260 | } 261 | 262 | /* TODO (2024-12-02): make it possible for an admin to reset another admin's password; maybe using quorum? */ 263 | 264 | func (h *RequestHandler) AdminResetUserPassword(res http.ResponseWriter, req *http.Request, targetUserId int) { 265 | ed := util.Describe("admin reset password") 266 | loggedIn, _ := h.IsLoggedIn(req) 267 | isAdmin, adminUserId := h.IsAdmin(req) 268 | if req.Method == "GET" || !loggedIn || !isAdmin { 269 | IndexRedirect(res, req) 270 | return 271 | } 272 | 273 | title := util.Capitalize(h.translator.Translate("PasswordReset")) 274 | newPassword, err := h.db.ResetPassword(targetUserId) 275 | 276 | if err != nil { 277 | h.displayErr(res, req, err, title) 278 | return 279 | } 280 | 281 | err = h.db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_RESETPW) 282 | if err != nil { 283 | fmt.Println(ed.Eout(err, "error adding moderation log")) 284 | } 285 | 286 | username, _ := h.db.GetUsername(targetUserId) 287 | 288 | message := fmt.Sprintf(h.translator.Translate("AdminPasswordSuccessInstructions"), template.HTMLEscapeString(username), newPassword) 289 | h.displaySuccess(res, req, title, message, "/admin") 290 | } 291 | 292 | func (h *RequestHandler) ConfirmProposal(res http.ResponseWriter, req *http.Request) { 293 | h.HandleProposal(res, req, constants.PROPOSAL_CONFIRM) 294 | } 295 | 296 | func (h *RequestHandler) VetoProposal(res http.ResponseWriter, req *http.Request) { 297 | h.HandleProposal(res, req, constants.PROPOSAL_VETO) 298 | } 299 | 300 | func (h *RequestHandler) HandleProposal(res http.ResponseWriter, req *http.Request, decision bool) { 301 | ed := util.Describe("handle proposal proposal") 302 | isAdmin, adminUserId := h.IsAdmin(req) 303 | 304 | if !isAdmin { 305 | IndexRedirect(res, req) 306 | return 307 | } 308 | 309 | if req.Method == "POST" { 310 | proposalidString := req.PostFormValue("proposalid") 311 | proposalid, err := strconv.Atoi(proposalidString) 312 | ed.Check(err, "convert proposalid") 313 | err = h.db.FinalizeProposedAction(proposalid, adminUserId, decision) 314 | if err != nil { 315 | ed.Eout(err, "finalizing the proposed action returned early with an error") 316 | } 317 | http.Redirect(res, req, "/admin", http.StatusFound) 318 | return 319 | } 320 | IndexRedirect(res, req) 321 | } 322 | 323 | // Note: this route by definition contains user generated content, so we escape all usernames with 324 | // html.EscapeString(username) 325 | func (h *RequestHandler) ModerationLogRoute(res http.ResponseWriter, req *http.Request) { 326 | loggedIn, _ := h.IsLoggedIn(req) 327 | isAdmin, _ := h.IsAdmin(req) 328 | // logs are sorted by time descending, from latest entry to oldest 329 | logs := h.db.GetModerationLogs() 330 | viewData := ModerationData{Log: make([]string, 0)} 331 | 332 | type translationData struct { 333 | Time, ActingUsername, RecipientUsername string 334 | Action template.HTML 335 | } 336 | 337 | for _, entry := range logs { 338 | var tdata translationData 339 | var translationString string 340 | tdata.Time = entry.Time.Format("2006-01-02 15:04:05") 341 | tdata.ActingUsername = template.HTMLEscapeString(entry.ActingUsername) 342 | tdata.RecipientUsername = template.HTMLEscapeString(entry.RecipientUsername) 343 | switch entry.Action { 344 | case constants.MODLOG_RESETPW: 345 | translationString = "modlogResetPassword" 346 | if isAdmin { 347 | translationString += "Admin" 348 | } 349 | case constants.MODLOG_ADMIN_MAKE: 350 | translationString = "modlogMakeAdmin" 351 | case constants.MODLOG_REMOVE_USER: 352 | translationString = "modlogRemoveUser" 353 | case constants.MODLOG_ADMIN_ADD_USER: 354 | translationString = "modlogAddUser" 355 | if isAdmin { 356 | translationString += "Admin" 357 | } 358 | case constants.MODLOG_ADMIN_DEMOTE: 359 | translationString = "modlogDemoteAdmin" 360 | case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: 361 | translationString = "modlogProposalDemoteAdmin" 362 | case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: 363 | translationString = "modlogProposalMakeAdmin" 364 | case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: 365 | translationString = "modlogProposalRemoveUser" 366 | case constants.MODLOG_CREATE_INVITE_BATCH: 367 | translationString = "modlogCreateInvites" 368 | case constants.MODLOG_DELETE_INVITE_BATCH: 369 | translationString = "modlogDeleteInvites" 370 | } 371 | 372 | actionString := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: tdata}) 373 | 374 | /* rendering of decision (confirm/veto) taken on a pending proposal */ 375 | if entry.QuorumUsername != "" { 376 | // use the translated actionString to embed in the translated proposal decision (confirmation/veto) 377 | propdata := translationData{ActingUsername: template.HTMLEscapeString(entry.QuorumUsername), Action: template.HTML(actionString)} 378 | // if quorumDecision is true -> proposal was confirmed 379 | translationString = "modlogConfirm" 380 | if !entry.QuorumDecision { 381 | translationString = "modlogVeto" 382 | } 383 | proposalString := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: propdata}) 384 | viewData.Log = append(viewData.Log, proposalString) 385 | /* rendering of "X proposed: " */ 386 | } else if entry.Action == constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN || 387 | entry.Action == constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN || 388 | entry.Action == constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER { 389 | propXforY := translationData{Time: tdata.Time, ActingUsername: tdata.ActingUsername, Action: template.HTML(actionString)} 390 | proposalString := h.translator.TranslateWithData("modlogXProposedY", i18n.TranslationData{Data: propXforY}) 391 | viewData.Log = append(viewData.Log, proposalString) 392 | } else { 393 | viewData.Log = append(viewData.Log, actionString) 394 | } 395 | } 396 | view := TemplateData{Title: h.translator.Translate("ModerationLog"), IsAdmin: isAdmin, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Data: viewData} 397 | h.renderView(res, "moderation-log", view) 398 | } 399 | 400 | // used for rendering /admin's pending proposals 401 | func (h *RequestHandler) AdminRoute(res http.ResponseWriter, req *http.Request) { 402 | loggedIn, userid := h.IsLoggedIn(req) 403 | isAdmin, _ := h.IsAdmin(req) 404 | 405 | if req.Method == "POST" && loggedIn && isAdmin { 406 | action := req.PostFormValue("admin-action") 407 | useridString := req.PostFormValue("userid") 408 | targetUserId, err := strconv.Atoi(useridString) 409 | util.Check(err, "convert user id string to a plain userid") 410 | 411 | switch action { 412 | case "reset-password": 413 | h.AdminResetUserPassword(res, req, targetUserId) 414 | case "make-admin": 415 | h.AdminMakeUserAdmin(res, req, targetUserId) 416 | case "remove-account": 417 | h.AdminRemoveUser(res, req, targetUserId) 418 | } 419 | return 420 | } 421 | 422 | if req.Method == "GET" { 423 | if !loggedIn || !isAdmin { 424 | // non-admin users get a different view 425 | h.ListAdmins(res, req) 426 | return 427 | } 428 | admins := h.db.GetAdmins() 429 | normalUsers := h.db.GetUsers(false) // do not include admins 430 | registrations := h.db.CountRegistrationsByInviteBatch() 431 | proposedActions := h.db.GetProposedActions() 432 | // massage pending proposals into something we can use in the rendered view 433 | pendingProposals := make([]PendingProposal, len(proposedActions)) 434 | now := time.Now() 435 | for i, prop := range proposedActions { 436 | // escape all ugc 437 | prop.ActingUsername = template.HTMLEscapeString(prop.ActingUsername) 438 | prop.RecipientUsername = template.HTMLEscapeString(prop.RecipientUsername) 439 | // one week from when the proposal was made 440 | t := prop.Time.Add(constants.PROPOSAL_SELF_CONFIRMATION_WAIT) 441 | var str string 442 | switch prop.Action { 443 | case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: 444 | str = "modlogProposalDemoteAdmin" 445 | case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: 446 | str = "modlogProposalMakeAdmin" 447 | case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: 448 | str = "modlogProposalRemoveUser" 449 | } 450 | 451 | proposalString := h.translator.TranslateWithData(str, i18n.TranslationData{Data: prop}) 452 | pendingProposals[i] = PendingProposal{ID: prop.ProposalID, ProposerID: prop.ActingID, Action: proposalString, Time: t, TimePassed: now.After(t)} 453 | } 454 | data := AdminData{Admins: admins, Users: normalUsers, Proposals: pendingProposals, Registrations: registrations} 455 | view := TemplateData{Title: h.translator.Translate("AdminForumAdministration"), Data: &data, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid} 456 | h.renderView(res, "admin", view) 457 | } 458 | } 459 | 460 | func (h *RequestHandler) AdminInvitesRoute(res http.ResponseWriter, req *http.Request) { 461 | // ed := util.Describe("admin invites route") 462 | loggedIn, _ := h.IsLoggedIn(req) 463 | isAdmin, _ := h.IsAdmin(req) 464 | 465 | if !isAdmin { 466 | IndexRedirect(res, req) 467 | return 468 | } 469 | 470 | batches := h.db.GetAllInvites() 471 | 472 | type Invites struct { 473 | ErrorMessage string 474 | CreateRoute string 475 | DeleteRoute string 476 | ForumRootURL string 477 | Batches []database.InviteBatch 478 | } 479 | 480 | var data Invites 481 | data.CreateRoute = INVITES_CREATE_ROUTE 482 | data.DeleteRoute = INVITES_DELETE_ROUTE 483 | data.Batches = batches 484 | // reuse the root url to better display registration links on the invites panel 485 | if h.config.RSS.URL != "" { 486 | data.ForumRootURL = h.config.RSS.URL 487 | } 488 | 489 | view := TemplateData{Title: "Invites", Data: &data, HasRSS: h.config.RSS.URL != "", IsAdmin: isAdmin, LoggedIn: loggedIn} 490 | 491 | if req.Method == "GET" { 492 | h.renderView(res, "admin-invites", view) 493 | return 494 | } else { 495 | fmt.Println(INVITES_ROUTE, "received request of type other than GET") 496 | IndexRedirect(res, req) 497 | } 498 | } 499 | 500 | func (h *RequestHandler) AdminInvitesCreateBatch(res http.ResponseWriter, req *http.Request) { 501 | ed := util.Describe("server: admin generate invites") 502 | loggedIn, _ := h.IsLoggedIn(req) 503 | isAdmin, adminUserId := h.IsAdmin(req) 504 | if req.Method == "GET" || !loggedIn || !isAdmin { 505 | IndexRedirect(res, req) 506 | return 507 | } 508 | amount, err := strconv.Atoi(req.PostFormValue("amount")) 509 | util.Check(err, "parse amount as int") 510 | var label string 511 | label = req.PostFormValue("label") 512 | reusable := (req.PostFormValue("reusable") == "true") 513 | err = h.db.CreateInvites(adminUserId, amount, label, reusable) 514 | if err != nil { 515 | fmt.Printf("%v\n", ed.Eout(err, "create invites")) 516 | return 517 | } 518 | 519 | modlogErr := h.db.AddModerationLog(adminUserId, -1, constants.MODLOG_CREATE_INVITE_BATCH) 520 | if modlogErr != nil { 521 | fmt.Println(ed.Eout(modlogErr, "error adding moderation log")) 522 | } 523 | 524 | // refresh and show the create invite section 525 | http.Redirect(res, req, fmt.Sprintf("%s%s", INVITES_ROUTE, "#create-invites"), http.StatusFound) 526 | } 527 | 528 | func (h *RequestHandler) AdminInvitesDeleteBatch(res http.ResponseWriter, req *http.Request) { 529 | ed := util.Describe("server: admin delete invites") 530 | loggedIn, _ := h.IsLoggedIn(req) 531 | isAdmin, adminUserId := h.IsAdmin(req) 532 | if req.Method == "GET" || !loggedIn || !isAdmin { 533 | IndexRedirect(res, req) 534 | return 535 | } 536 | batchId := req.PostFormValue("batchid") 537 | h.db.DeleteInvitesBatch(batchId) 538 | modlogErr := h.db.AddModerationLog(adminUserId, -1, constants.MODLOG_DELETE_INVITE_BATCH) 539 | if modlogErr != nil { 540 | fmt.Println(ed.Eout(modlogErr, "error adding moderation log")) 541 | } 542 | // refresh and show the create invite section 543 | http.Redirect(res, req, fmt.Sprintf("%s%s", INVITES_ROUTE, "#create-invites"), http.StatusFound) 544 | } 545 | 546 | // view of /admin for non-admin users (contains less information) 547 | func (h *RequestHandler) ListAdmins(res http.ResponseWriter, req *http.Request) { 548 | loggedIn, _ := h.IsLoggedIn(req) 549 | admins := h.db.GetAdmins() 550 | data := AdminData{Admins: admins} 551 | view := TemplateData{Title: h.translator.Translate("AdminForumAdministration"), Data: &data, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn} 552 | h.renderView(res, "admins-list", view) 553 | return 554 | } 555 | -------------------------------------------------------------------------------- /server/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | /* 4 | Copyright (c) 2019 m15o . All rights reserved. 5 | Copyright (c) 2022 cblgh . All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, 8 | are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, 11 | this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 21 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 26 | USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | */ 28 | 29 | import ( 30 | "cerca/util" 31 | "errors" 32 | "fmt" 33 | "net/http" 34 | 35 | "github.com/gorilla/sessions" 36 | ) 37 | 38 | const cookieName = "cerca" 39 | 40 | const INDEX_SETTINGS = "IndexSettings" 41 | const USER_ID = "userid" 42 | 43 | type Session struct { 44 | Store *sessions.CookieStore 45 | ShortLivedStore *sessions.CookieStore 46 | } 47 | 48 | func New(authKey string, developing bool) *Session { 49 | store := sessions.NewCookieStore([]byte(authKey)) 50 | store.Options = &sessions.Options{ 51 | HttpOnly: true, 52 | Secure: !developing, 53 | MaxAge: 86400 * 30, 54 | } 55 | short := sessions.NewCookieStore([]byte(authKey)) 56 | short.Options = &sessions.Options{ 57 | HttpOnly: true, 58 | // Secure: true, // TODO (2022-01-05): uncomment when served over https 59 | MaxAge: 600, // 10 minutes 60 | } 61 | return &Session{ 62 | Store: store, 63 | ShortLivedStore: short, 64 | } 65 | } 66 | 67 | func (s *Session) Delete(res http.ResponseWriter, req *http.Request) error { 68 | ed := util.Describe("delete session cookie") 69 | clearSession := func(store *sessions.CookieStore) error { 70 | session, err := store.Get(req, cookieName) 71 | if err != nil { 72 | return ed.Eout(err, "get session") 73 | } 74 | session.Options.MaxAge = -1 75 | err = session.Save(req, res) 76 | return ed.Eout(err, "save expired session") 77 | } 78 | err := clearSession(s.Store) 79 | if err != nil { 80 | return err 81 | } 82 | err = clearSession(s.ShortLivedStore) 83 | return err 84 | } 85 | 86 | func getValueFromSession(req *http.Request, store *sessions.CookieStore, key string) (interface{}, error) { 87 | session, err := store.Get(req, cookieName) 88 | if err != nil { 89 | return nil, err 90 | } 91 | value, ok := session.Values[key] 92 | if !ok { 93 | err := errors.New(fmt.Sprintf("extracting %s from session; no such value", key)) 94 | return nil, util.Eout(err, "get session") 95 | } 96 | return value, nil 97 | } 98 | 99 | func (s *Session) Get(req *http.Request) (int, error) { 100 | val, err := getValueFromSession(req, s.Store, USER_ID) 101 | if val == nil || err != nil { 102 | return -1, err 103 | } 104 | return val.(int), err 105 | } 106 | 107 | /* TODO (2024-11-20): revamp structure of this file to something less repetitive and using enum-like things instead */ 108 | func (s *Session) GetIndexSettings(req *http.Request) (string, error) { 109 | val, err := getValueFromSession(req, s.Store, INDEX_SETTINGS) 110 | if val == nil || err != nil { 111 | return "", err 112 | } 113 | return val.(string), err 114 | } 115 | 116 | func (s *Session) genericSave(req *http.Request, res http.ResponseWriter, shortLived bool, key string, val interface{}) error { 117 | store := s.Store 118 | if shortLived { 119 | store = s.ShortLivedStore 120 | } 121 | session, _ := store.Get(req, cookieName) 122 | session.Values[key] = val 123 | return session.Save(req, res) 124 | } 125 | 126 | func (s *Session) Save(req *http.Request, res http.ResponseWriter, userid int) error { 127 | return s.genericSave(req, res, false, USER_ID, userid) 128 | } 129 | 130 | func (s *Session) SaveIndexSettings(req *http.Request, res http.ResponseWriter, params string) error { 131 | return s.genericSave(req, res, false, INDEX_SETTINGS, params) 132 | } 133 | -------------------------------------------------------------------------------- /server/util.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | 6 | "cerca/crypto" 7 | "cerca/util" 8 | ) 9 | 10 | func (h *RequestHandler) checkPasswordIsCorrect(userid int, password string) error { 11 | ed := util.Describe("checkPasswordIsCorrect") 12 | // * hash received password and compare to stored hash 13 | passwordHash, err := h.db.GetPasswordHashByUserID(userid) 14 | if err = ed.Eout(err, "getting password hash and uid"); err == nil && !crypto.ValidatePasswordHash(password, passwordHash) { 15 | return errors.New("hashing the supplied password did not result in what was stored in the database") 16 | } 17 | if err != nil { 18 | return errors.New("password check failed") 19 | } 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "path/filepath" 5 | ) 6 | 7 | type Config struct { 8 | Community struct { 9 | Name string `json:"name"` 10 | ConductLink string `json:"conduct_url"` // optional 11 | Language string `json:"language"` 12 | } `json:"general"` 13 | 14 | RSS struct { 15 | Name string `json:"feed_name"` 16 | Description string `json:"feed_description"` 17 | URL string `json:"forum_url"` 18 | } `json:"rss"` 19 | 20 | Documents struct { 21 | LogoPath string `json:"logo"` 22 | AboutPath string `json:"about"` 23 | RegisterRulesPath string `json:"rules"` 24 | RegistrationExplanationPath string `json:"registration_instructions"` 25 | } `json:"documents"` 26 | } 27 | 28 | // Ensure that, at the very least, default paths exist for each expected document path. Does not overwrite previously set values. 29 | func (c *Config) EnsureDefaultPaths() { 30 | if c.Documents.AboutPath == "" { 31 | c.Documents.AboutPath = filepath.Join("content", "about.md") 32 | } 33 | if c.Documents.RegisterRulesPath == "" { 34 | c.Documents.RegisterRulesPath = filepath.Join("content", "rules.md") 35 | } 36 | if c.Documents.RegistrationExplanationPath == "" { 37 | c.Documents.RegistrationExplanationPath = filepath.Join("content", "registration-instructions.md") 38 | } 39 | if c.Documents.LogoPath == "" { 40 | c.Documents.LogoPath = filepath.Join("content", "logo.html") 41 | } 42 | } 43 | 44 | /* 45 | config structure: 46 | ["general"] 47 | name = "Merveilles" 48 | conduct_link = "https://github.com/merveilles/Resources/blob/master/CONDUCT.md" 49 | language = "English" 50 | 51 | 52 | ["documents"] 53 | logo = "./logo.svg" 54 | about = "./about.md" 55 | rules = "./rules.md" 56 | registration_instructions = "./registration-instructions.md" 57 | */ 58 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/hex" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "html/template" 11 | "log" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "path/filepath" 16 | "regexp" 17 | "strconv" 18 | "strings" 19 | "time" 20 | 21 | "github.com/gomarkdown/markdown" 22 | "github.com/gomarkdown/markdown/ast" 23 | "github.com/gomarkdown/markdown/html" 24 | "github.com/gomarkdown/markdown/parser" 25 | "github.com/google/uuid" 26 | "github.com/komkom/toml" 27 | "github.com/microcosm-cc/bluemonday" 28 | "golang.org/x/exp/utf8string" 29 | 30 | "cerca/defaults" 31 | "cerca/types" 32 | ) 33 | 34 | /* util.Eout example invocations 35 | if err != nil { 36 | return util.Eout(err, "reading data") 37 | } 38 | if err = util.Eout(err, "reading data"); err != nil { 39 | return nil, err 40 | } 41 | */ 42 | 43 | type ErrorDescriber struct { 44 | environ string // the basic context that is potentially generating errors (like a GetThread function, the environ would be "get thread") 45 | } 46 | 47 | // parametrize Eout/Check such that error messages contain a defined context/environ 48 | func Describe(environ string) ErrorDescriber { 49 | return ErrorDescriber{environ} 50 | } 51 | 52 | func (ed ErrorDescriber) Eout(err error, msg string, args ...interface{}) error { 53 | msg = fmt.Sprintf("%s: %s", ed.environ, msg) 54 | return Eout(err, msg, args...) 55 | } 56 | 57 | func (ed ErrorDescriber) Check(err error, msg string, args ...interface{}) { 58 | msg = fmt.Sprintf("%s: %s", ed.environ, msg) 59 | Check(err, msg, args...) 60 | } 61 | 62 | // format all errors consistently, and provide context for the error using the string `msg` 63 | func Eout(err error, msg string, args ...interface{}) error { 64 | if err != nil { 65 | // received an invocation of e.g. format: 66 | // Eout(err, "reading data for %s and %s", "database item", "weird user") 67 | if len(args) > 0 { 68 | return fmt.Errorf("%s (%w)", fmt.Sprintf(msg, args...), err) 69 | } 70 | return fmt.Errorf("%s (%w)", msg, err) 71 | } 72 | return nil 73 | } 74 | 75 | func Check(err error, msg string, args ...interface{}) { 76 | if len(args) > 0 { 77 | err = Eout(err, msg, args...) 78 | } else { 79 | err = Eout(err, msg) 80 | } 81 | if err != nil { 82 | log.Fatalln(err) 83 | } 84 | } 85 | 86 | func Contains(slice []string, s string) bool { 87 | for _, item := range slice { 88 | if item == s { 89 | return true 90 | } 91 | } 92 | return false 93 | } 94 | 95 | var contentGuardian = bluemonday.UGCPolicy() 96 | var strictContentGuardian = bluemonday.StrictPolicy() 97 | 98 | func modifyAst(doc ast.Node) ast.Node { 99 | ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus { 100 | if img, ok := node.(*ast.Image); ok && entering { 101 | // use the alt text of the image to set the `title` attribute, to enable 102 | // hover-over in a browser to show the image text 103 | imgChildren := img.GetChildren() 104 | if len(imgChildren) > 0 { 105 | if altTextNode, ok := imgChildren[0].(*ast.Text); ok { 106 | img.Title = altTextNode.Literal 107 | } 108 | } 109 | } 110 | return ast.GoToNext 111 | }) 112 | return doc 113 | } 114 | 115 | // Turns Markdown input into HTML 116 | func Markup(md string) template.HTML { 117 | mdBytes := []byte(string(md)) 118 | // fix newlines 119 | mdBytes = markdown.NormalizeNewlines(mdBytes) 120 | mdParser := parser.NewWithExtensions(parser.CommonExtensions ^ parser.MathJax) 121 | astDoc := mdParser.Parse(mdBytes) 122 | modifyAst(astDoc) 123 | renderer := html.NewRenderer(html.RendererOptions{Flags: html.CommonFlags}) 124 | maybeUnsafeHTML := markdown.Render(astDoc, renderer) 125 | // guard against malicious code being embedded 126 | html := contentGuardian.SanitizeBytes(maybeUnsafeHTML) 127 | // lazy load images 128 | pattern := regexp.MustCompile("