cerca

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

commit c799c2a9629395e17561d653d5bdb77a9742e20d
parent a192f455eef06015114d3b1d99dd584f6da483c9
Author: Alexander Cobleigh <cblgh@cblgh.org>
Date:   Wed, 17 Jan 2024 14:39:58 +0100

Merge pull request #54 from cblgh/aw-branch-fewer-commits

new contribs from aw and database migration
Diffstat:
M.gitignore | 1+
AMIGRATIONS.md | 30++++++++++++++++++++++++++++++
Acmd/add-admin/main.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/admin-add-user/main.go | 37++++++++++++++-----------------------
Mcmd/admin-reset/main.go | 60+++++++++++++++++-------------------------------------------
Acmd/migration-tool/main.go | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcmd/pwtool/main.go | 28----------------------------
Dcmd/pwtool/main_test.go | 19-------------------
Aconstants/constants.go | 21+++++++++++++++++++++
Mcrypto/crypto.go | 157++++++++-----------------------------------------------------------------------
Mdatabase/database.go | 184+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Adatabase/migrations.go | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adatabase/moderation.go | 512+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mgo.mod | 6+++---
Mgo.sum | 38++++++++++++++++++++++++++++++++++++++
Ahtml/admin-add-user.html | 21+++++++++++++++++++++
Ahtml/admin.html | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahtml/admins-list.html | 22++++++++++++++++++++++
Mhtml/change-password-success.html | 6------
Mhtml/change-password.html | 4----
Ahtml/edit-post.html | 17+++++++++++++++++
Mhtml/head.html | 7++++++-
Mhtml/index.html | 9++++++---
Ahtml/moderation-log.html | 32++++++++++++++++++++++++++++++++
Mhtml/password-reset.html | 18+-----------------
Mhtml/register-success.html | 8--------
Mhtml/register.html | 2++
Mhtml/thread.html | 10+++++++---
Mi18n/i18n.go | 140++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Aserver/moderation.go | 459+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/server.go | 311++++++++++++++++++++++++++++++-------------------------------------------------
Mutil/util.go | 41+++++++++++++++++++++++++++++++++++++++--
32 files changed, 2040 insertions(+), 620 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -3,4 +3,5 @@ data/ data/.gitkeep pwtool +admin-reset *.json diff --git a/MIGRATIONS.md b/MIGRATIONS.md @@ -0,0 +1,30 @@ +# Migrations + +This documents migrations for breaking database changes. These are intended to be as few as +possible, but sometimes they are necessary. + +## [2024-01-16] Migrating password hash libraries + +To support 32 bit architectures, such as running Cerca on an older Raspberry Pi, the password +hashing library used previously +[github.com/synacor/argon2id](https://github.com/synacor/argon2id) was swapped out for +[github.com/matthewhartstonge/argon2](https://github.com/matthewhartstonge/argon2). + +The password hashing algorithm remains the same, so users **do not** need to reset their +password, but the old encoding format needed migrating in the database. + +The new library also has a stronger default time parameter, which will be used for new +passwords while old passwords will use the time parameter stored together with their +database record. + +For more details, see [database/migrations.go](./database/migrations.go). + +Build and then run the migration tool in `cmd/migration-tool` accordingly: + +``` +cd cmd/migration-tool +go build +./migration-tool --database path-to-your-forum.db --migration 2024-01-password-hash-migration +``` + + diff --git a/cmd/add-admin/main.go b/cmd/add-admin/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "cerca/database" + "cerca/constants" + "flag" + "fmt" + "os" +) + +func inform(msg string, args ...interface{}) { + if len(args) > 0 { + fmt.Printf("%s\n", fmt.Sprintf(msg, args...)) + } else { + fmt.Printf("%s\n", msg) + } +} + +func complain(msg string, args ...interface{}) { + if len(args) > 0 { + inform(msg, args) + } else { + inform(msg) + } + os.Exit(0) +} + +func main() { + var username string + var forumDomain string + var dbPath string + flag.StringVar(&forumDomain, "url", "https://forum.merveilles.town", "root url to forum, referenced in output") + flag.StringVar(&username, "username", "", "username who should be made admin") + flag.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db") + flag.Parse() + + usage := `usage + add-admin --username <username to make admin> --url <rool url to forum> --database ./data/forum.db + add-admin --help for more information + ` + + adminRoute := fmt.Sprintf("%s/admin", forumDomain) + + if username == "" { + complain(usage) + } + + // check if database exists! we dont wanna create a new db in this case ':) + if !database.CheckExists(dbPath) { + complain("couldn't find database at %s", dbPath) + } + + db := database.InitDB(dbPath) + + userid, err := db.GetUserID(username) + if err != nil { + complain("username %s not in database", username) + } + inform("Attempting to make %s (id %d) admin...", username, userid) + err = db.AddAdmin(userid) + if err != nil { + complain("Something went wrong: %s", err) + } + + // log cmd actions just as admin web-actions are logged + systemUserid := db.GetSystemUserid() + err = db.AddModerationLog(systemUserid, userid, constants.MODLOG_ADMIN_MAKE) + if err != nil { + complain("adding mod log for adding new admin failed (%w)", err) + } + + inform("Successfully added %s (id %d) as an admin", username, userid) + inform("Please visit %s for all your administration needs (changing usernames, resetting passwords, deleting user accounts)", adminRoute) + inform("Admin action has been logged to /moderations") +} diff --git a/cmd/admin-add-user/main.go b/cmd/admin-add-user/main.go @@ -3,6 +3,7 @@ package main import ( "cerca/crypto" "cerca/database" + "cerca/constants" "cerca/util" "flag" "fmt" @@ -10,7 +11,8 @@ import ( ) type UserInfo struct { - Username, Password, Keypair string + ID int + Username, Password string } func createUser (username, password string, db *database.DB) UserInfo { @@ -35,22 +37,7 @@ func createUser (username, password string, db *database.DB) UserInfo { if err = ed.Eout(err, "add registration"); err != nil { complain("Database had a problem saving user registration location") } - // generate and pass public keypair - keypair, err := crypto.GenerateKeypair() - ed.Check(err, "generate keypair") - // record generated pubkey in database for eventual later use - pub, err := keypair.PublicString() - if err = ed.Eout(err, "convert pubkey to string"); err != nil { - complain("Can't convert pubkey to string") - } - ed.Check(err, "stringify pubkey") - err = db.AddPubkey(userID, pub) - if err = ed.Eout(err, "insert pubkey in db"); err != nil { - complain("Database had a problem saving user registration") - } - kpJson, err := keypair.Marshal() - ed.Check(err, "marshal keypair") - return UserInfo{Username: username, Password: password, Keypair: string(kpJson)} + return UserInfo{ID: userID, Username: username, Password: password} } func inform(msg string, args ...interface{}) { @@ -73,9 +60,7 @@ func complain(msg string, args ...interface{}) { func main() { var username string var forumDomain string - var keypairFlag bool var dbPath string - flag.BoolVar(&keypairFlag, "keypair", false, "output the keypair") flag.StringVar(&forumDomain, "url", "https://forum.merveilles.town", "root url to forum, referenced in output") flag.StringVar(&username, "username", "", "username whose credentials should be reset") flag.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db") @@ -96,17 +81,23 @@ func main() { } db := database.InitDB(dbPath) + newPassword := crypto.GeneratePassword() - info := createUser(username, newPassword, &db) + userInfo := createUser(username, newPassword, &db) + + // log cmd actions just as admin web-actions are logged + systemUserid := db.GetSystemUserid() + err := db.AddModerationLog(systemUserid, userInfo.ID, constants.MODLOG_ADMIN_ADD_USER) + if err != nil { + complain("adding mod log for adding new user failed (%w)", err) + } loginRoute := fmt.Sprintf("%s/login", forumDomain) resetRoute := fmt.Sprintf("%s/reset", forumDomain) inform("[user]\n%s", username) inform("[password]\n%s", newPassword) - if keypairFlag { - inform("[keypair]\n%s", info.Keypair) - } inform("Please login at %s\n", loginRoute) inform("After logging in, visit %s to reset your password", resetRoute) + inform("Admin action has been logged to /moderations") } diff --git a/cmd/admin-reset/main.go b/cmd/admin-reset/main.go @@ -1,9 +1,8 @@ package main import ( - "cerca/crypto" "cerca/database" - "cerca/util" + "cerca/constants" "flag" "fmt" "os" @@ -27,37 +26,23 @@ func complain(msg string, args ...interface{}) { } func main() { - var keypairFlag bool - var passwordFlag bool var username string var dbPath string flag.StringVar(&username, "username", "", "username whose credentials should be reset") flag.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db") - flag.BoolVar(&keypairFlag, "keypair", false, "reset the keypair") - flag.BoolVar(&passwordFlag, "password", false, "reset the password. if true generates a random new password") flag.Parse() usage := `usage - admin-reset --database ./data/forum.db --username <username to reset> [--keypair, --password] + admin-reset --database ./data/forum.db --username <username to reset> admin-reset --help for more information - examples: - # only reset the keypair, leaving the password intact - ./admin-reset --database ../../testdata/forum.db --username bambas --keypair - - # reset password only - ./admin-reset --database ../../testdata/forum.db --username bambas --password - - # reset both password and keypair - ./admin-reset --database ../../testdata/forum.db --username bambas --password --keypair + # example + ./admin-reset --database ../../testdata/forum.db --username bambas ` if username == "" { complain(usage) } - if !keypairFlag && !passwordFlag { - complain("nothing to reset, exiting") - } // check if database exists! we dont wanna create a new db in this case ':) if !database.CheckExists(dbPath) { @@ -65,36 +50,25 @@ func main() { } db := database.InitDB(dbPath) - ed := util.Describe("admin reset") userid, err := db.GetUserID(username) if err != nil { - complain("username %s not in database", username) + complain("reset password failed (%w)", err) } + newPassword, err := db.ResetPassword(userid) - // generate new password for user and set it in the database - if passwordFlag { - newPassword := crypto.GeneratePassword() - passwordHash, err := crypto.HashPassword(newPassword) - ed.Check(err, "hash new password") - db.UpdateUserPasswordHash(userid, passwordHash) - - inform("successfully updated %s's password hash", username) - inform("new temporary password %s", newPassword) + if err != nil { + complain("reset password failed (%w)", err) } - // generate a new keypair for user and update user's pubkey record with new pubkey - if keypairFlag { - kp, err := crypto.GenerateKeypair() - ed.Check(err, "generate keypair") - kpBytes, err := kp.Marshal() - ed.Check(err, "marshal keypair") - pubkey, err := kp.PublicString() - ed.Check(err, "get pubkey string") - err = db.SetPubkey(userid, pubkey) - ed.Check(err, "set new pubkey in database") - - inform("successfully changed %s's stored public key", username) - inform("new keypair\n%s", string(kpBytes)) + // log cmd actions just as admin web-actions are logged + systemUserid := db.GetSystemUserid() + err = db.AddModerationLog(systemUserid, userid, constants.MODLOG_RESETPW) + if err != nil { + complain("adding mod log for password reset failed (%w)", err) } + + inform("Successfully updated %s's password hash", username) + inform("New temporary password: %s", newPassword) + inform("Admin action has been logged to /moderations") } diff --git a/cmd/migration-tool/main.go b/cmd/migration-tool/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "cerca/database" + "flag" + "fmt" + "os" +) + +func inform(msg string, args ...interface{}) { + if len(args) > 0 { + fmt.Printf("%s\n", fmt.Sprintf(msg, args...)) + } else { + fmt.Printf("%s\n", msg) + } +} + +func complain(msg string, args ...interface{}) { + if len(args) > 0 { + inform(msg, args) + } else { + inform(msg) + } + os.Exit(0) +} + + +func main() { + migrations := map[string]func(string) error {"2024-01-password-hash-migration": database.Migration20240116_PwhashChange} + + var dbPath, migration string + var listMigrations bool + flag.BoolVar(&listMigrations, "list", false, "list possible migrations") + flag.StringVar(&migration, "migration", "", "name of the migration you want to perform on the database") + flag.StringVar(&dbPath, "database", "./data/forum.db", "full path to the forum database; e.g. ./data/forum.db") + flag.Parse() + + usage := `usage + migration-tool --list + migration-tool --migration <name of migration> + ` + + if listMigrations { + inform("Possible migrations:") + for key := range migrations { + fmt.Println("\t", key) + } + os.Exit(0) + } + + if migration == "" { + complain(usage) + } else if _, ok := migrations[migration]; !ok { + complain(fmt.Sprintf("chosen migration »%s» does not match one of the available migrations. see migrations with flag --list", migration)) + } + + // check if database exists! we dont wanna create a new db in this case ':) + if !database.CheckExists(dbPath) { + complain("couldn't find database at %s", dbPath) + } + + // perform migration + err := migrations[migration](dbPath) + if err == nil { + inform(fmt.Sprintf("Migration »%s» completed", migration)) + } else { + complain("migration terminated early due to error") + } +} diff --git a/cmd/pwtool/main.go b/cmd/pwtool/main.go @@ -1,28 +0,0 @@ -package main - -import ( - "cerca/crypto" - "flag" - "fmt" - "os" -) - -func main() { - var kpPath string - var payload string - flag.StringVar(&kpPath, "keypair", "", "path to your account-securing public-keypair (the thing you got during registering and was told to save:)") - flag.StringVar(&payload, "payload", "", "the payload presented on the restore password page") - flag.Parse() - - if kpPath == "" { - fmt.Println(`usage: - tool --keypair <path-to-keypair.json> --payload <payload from website> - tool --help for more information`) - os.Exit(0) - } - kp, _ := crypto.ReadKeypair(kpPath) - proof := crypto.CreateProof(kp, []byte(payload)) - fmt.Println("your proof:") - fmt.Println(fmt.Sprintf("%x", proof)) - fmt.Println("\nplease paste the proof in the proof box of the restore password page, thank you!") -} diff --git a/cmd/pwtool/main_test.go b/cmd/pwtool/main_test.go @@ -1,19 +0,0 @@ -package main_test - -import ( - "cerca/crypto" - "github.com/stretchr/testify/assert" - "testing" -) - -func TestMain(t *testing.T) { - a := assert.New(t) - - kp, err := crypto.GenerateKeypair() - a.NoError(err) - msg := []byte("hi") - proof := crypto.CreateProof(kp, msg) - a.NotZero(len(proof), "proof length greater than zero") - proofVerificationCorrect := crypto.VerifyProof(kp.Public, msg, proof) - a.True(proofVerificationCorrect) -} diff --git a/constants/constants.go b/constants/constants.go @@ -0,0 +1,21 @@ +package constants +import "time" + +const ( + MODLOG_RESETPW = iota + MODLOG_ADMIN_VETO // vetoing a proposal + MODLOG_ADMIN_MAKE // make an admin + MODLOG_REMOVE_USER // remove a user + MODLOG_ADMIN_ADD_USER // add a new user + MODLOG_ADMIN_DEMOTE // demote an admin back to a normal user + MODLOG_ADMIN_CONFIRM // confirming a proposal + MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN + MODLOG_ADMIN_PROPOSE_MAKE_ADMIN + MODLOG_ADMIN_PROPOSE_REMOVE_USER + /* NOTE: when adding new values, only add them after already existing values! otherwise the existing variables will + * receive new values which affects the stored values in table moderation_log */ +) + +const PROPOSAL_VETO = false +const PROPOSAL_CONFIRM = true +const PROPOSAL_SELF_CONFIRMATION_WAIT = time.Hour * 24 * 7 /* 1 week */ diff --git a/crypto/crypto.go b/crypto/crypto.go @@ -2,166 +2,33 @@ package crypto import ( "cerca/util" - "crypto/ed25519" crand "crypto/rand" - "encoding/base64" "encoding/binary" - "encoding/json" - "fmt" - "github.com/synacor/argon2id" + "github.com/matthewhartstonge/argon2" "math/big" rand "math/rand" - "os" "strings" - "time" ) -type Keypair struct { - Public ed25519.PublicKey `json:"public"` - Private ed25519.PrivateKey `json:"private"` -} - -func GenerateKeypair() (Keypair, error) { - ed := util.Describe("generate public keypair") - pub, priv, err := ed25519.GenerateKey(nil) - if err != nil { - return Keypair{}, ed.Eout(err, "generating key") - } - return Keypair{Public: pub, Private: priv}, nil -} - -func ReadKeypair(kpPath string) (Keypair, error) { - var kp Keypair - ed := util.Describe("read keypair from disk") - b, err := os.ReadFile(kpPath) - if err != nil { - return kp, ed.Eout(err, "read file") - } - err = kp.Unmarshal(b) - if err != nil { - return kp, ed.Eout(err, "unmarshal kp") - } - return kp, nil -} - -func CreateProof(kp Keypair, payload []byte) []byte { - return ed25519.Sign(kp.Private, payload) -} - -func VerifyProof(public ed25519.PublicKey, payload, proof []byte) bool { - return ed25519.Verify(public, payload, proof) -} - -/* kinda cludgy oh well */ -func (kp *Keypair) PublicString() (string, error) { - b, err := json.Marshal(kp.Public) - if err != nil { - return "", util.Eout(err, "marshal public key") - } - return string(b), nil -} - -func (kp *Keypair) PrivateString() (string, error) { - b, err := json.Marshal(kp.Private) - if err != nil { - return "", util.Eout(err, "marshal private key") - } - return string(b), nil -} - -func (kp *Keypair) Marshal() ([]byte, error) { - jason, err := json.MarshalIndent(kp, "", " ") - if err != nil { - return []byte{}, util.Eout(err, "marshal keypair") - } - return jason, nil -} - -func (kp *Keypair) Unmarshal(input []byte) error { - ed := util.Describe("unmarshal keypair") - type stringKp struct { - Public string `json:"public"` - Private string `json:"private"` - } - var m stringKp - err := json.Unmarshal(input, &m) - if err != nil { - return ed.Eout(err, "unmarshal into string struct") - } - - // handle the unfortunate case that the first generated keypairs were all in hex :') - // meaning: convert them from hex to base64 (the format expected by crypto/ed25519) - if len(m.Private) == 128 { - convertedVal, err := util.Hex2Base64(m.Private) - if err != nil { - return ed.Eout(err, "failed to convert privkey hex to base64") - } - m.Private = convertedVal - convertedVal, err = util.Hex2Base64(m.Public) - if err != nil { - return ed.Eout(err, "failed to convert pubkey hex to base64") - } - m.Public = convertedVal - - // marshal the corrected version to a slice of bytes, so that we can pretend this debacle never happened - input, err = json.Marshal(m) - if err != nil { - return ed.Eout(err, "failed to marshal converted hex") - } - } - - err = json.Unmarshal(input, kp) - if err != nil { - return ed.Eout(err, "unmarshal keypair") - } - return nil -} - -func PublicKeyFromString(s string) ed25519.PublicKey { - ed := util.Describe("public key from string") - var err error - // handle legacy case of some pubkeys being stored in wrong column due to faulty query <.< - s = strings.ReplaceAll(s, `"`, "") - // handle legacy case of some pubkeys being stored as hex - if len(s) == 64 { - s, err = util.Hex2Base64(s) - ed.Check(err, "convert hex to base64") - } - b, err := base64.StdEncoding.DecodeString(s) - ed.Check(err, "decode base64 string") - pub := (ed25519.PublicKey)(b) - return pub -} - func HashPassword(s string) (string, error) { ed := util.Describe("hash password") - hash, err := argon2id.DefaultHashPassword(s) + config := argon2.MemoryConstrainedDefaults() + hash, err := config.HashEncoded([]byte(s)) if err != nil { return "", ed.Eout(err, "hashing with argon2id") } - return hash, nil + return string(hash), nil } func ValidatePasswordHash(password, passwordHash string) bool { - err := argon2id.Compare(passwordHash, password) + ed := util.Describe("validate password hash") + hashStruct, err := argon2.Decode([]byte(passwordHash)) + ed.Check(err, "argon2.decode") + correct, err := hashStruct.Verify([]byte(password)) if err != nil { return false } - return true -} - -func GenerateVerificationCode() int { - var src cryptoSource - rnd := rand.New(src) - return rnd.Intn(999999) -} - -func GenerateNonce() string { - const MaxUint = ^uint(0) - const MaxInt = int(MaxUint >> 1) - var src cryptoSource - rnd := rand.New(src) - return fmt.Sprintf("%d%d", time.Now().Unix(), rnd.Intn(MaxInt)) + return correct } // used for generating a random reset password @@ -182,6 +49,12 @@ func GeneratePassword() string { return password.String() } +func GenerateVerificationCode() int { + var src cryptoSource + rnd := rand.New(src) + return rnd.Intn(999999) +} + type cryptoSource struct{} func (s cryptoSource) Seed(seed int64) {} diff --git a/database/database.go b/database/database.go @@ -3,9 +3,9 @@ package database import ( "context" "database/sql" + "cerca/crypto" "errors" "fmt" - "html/template" "log" "net/url" "os" @@ -43,7 +43,29 @@ func InitDB(filepath string) DB { log.Fatalln("db is nil") } createTables(db) - return DB{db} + instance := DB{db} + instance.makeSureDefaultUsersExist() + return instance +} + +const DELETED_USER_NAME = "deleted user" +const SYSTEM_USER_NAME = "CERCA_CMD" + +func (d DB) makeSureDefaultUsersExist() { + ed := util.Describe("create default users") + for _, defaultUser := range []string{DELETED_USER_NAME, SYSTEM_USER_NAME} { + userExists, err := d.CheckUsernameExists(defaultUser) + if err != nil { + log.Fatalln(ed.Eout(err, "check username for %s exists", defaultUser)) + } + if !userExists { + passwordHash, err := crypto.HashPassword(crypto.GeneratePassword()) + _, err = d.CreateUser(defaultUser, passwordHash) + if err != nil { + log.Fatalln(ed.Eout(err, "create %s", defaultUser)) + } + } + } } func createTables(db *sql.DB) { @@ -63,19 +85,44 @@ func createTables(db *sql.DB) { ); `, ` - CREATE TABLE IF NOT EXISTS nonces ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nonce TEXT NOT NULL UNIQUE + CREATE TABLE IF NOT EXISTS admins( + id INTEGER PRIMARY KEY ); `, + /* add optional columns: quorumuser quorum_action (confirm, veto)? */ ` - CREATE TABLE IF NOT EXISTS pubkeys ( + CREATE TABLE IF NOT EXISTS moderation_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, - pubkey TEXT NOT NULL UNIQUE, - userid integer NOT NULL UNIQUE, - FOREIGN KEY (userid) REFERENCES users(id) + actingid INTEGER NOT NULL, + recipientid INTEGER, + action INTEGER NOT NULL, + time DATE NOT NULL, + + FOREIGN KEY (actingid) REFERENCES users(id), + FOREIGN KEY (recipientid) REFERENCES users(id) ); `, + ` + CREATE TABLE IF NOT EXISTS quorum_decisions ( + userid INTEGER NOT NULL, + decision BOOL NOT NULL, + modlogid INTEGER NOT NULL, + + FOREIGN KEY (modlogid) REFERENCES moderation_log(id) + ); + `, + ` + CREATE TABLE IF NOT EXISTS moderation_proposals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proposerid INTEGER NOT NULL, + recipientid INTEGER NOT NULL, + action INTEGER NOT NULL, + time DATE NOT NULL, + + FOREIGN KEY (proposerid) REFERENCES users(id), + FOREIGN KEY (recipientid) REFERENCES users(id) + ); + `, ` CREATE TABLE IF NOT EXISTS registrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -178,7 +225,8 @@ func (d DB) CreateThread(title, content string, authorid, topicid int) (int, err type Post struct { ID int ThreadTitle string - Content template.HTML + ThreadID int + Content string // markdown Author string AuthorID int Publish time.Time @@ -226,14 +274,14 @@ func (d DB) GetThread(threadid int) []Post { func (d DB) GetPost(postid int) (Post, error) { stmt := ` - SELECT p.id, t.title, content, u.name, p.authorid, p.publishtime, p.lastedit + SELECT p.id, t.title, t.id, content, u.name, p.authorid, p.publishtime, p.lastedit FROM posts p INNER JOIN users u ON u.id = p.authorid INNER JOIN threads t ON t.id = p.threadid WHERE p.id = ? ` var data Post - err := d.db.QueryRow(stmt, postid).Scan(&data.ID, &data.ThreadTitle, &data.Content, &data.Author, &data.AuthorID, &data.Publish, &data.LastEdit) + err := d.db.QueryRow(stmt, postid).Scan(&data.ID, &data.ThreadTitle, &data.ThreadID, &data.Content, &data.Author, &data.AuthorID, &data.Publish, &data.LastEdit) err = util.Eout(err, "get data for thread %d", postid) return data, err } @@ -387,11 +435,6 @@ func (d DB) CheckUserExists(userid int) (bool, error) { return d.existsQuery(stmt, userid) } -func (d DB) CheckNonceExists(nonce string) (bool, error) { - stmt := `SELECT 1 FROM nonces WHERE nonce = ?` - return d.existsQuery(stmt, nonce) -} - func (d DB) CheckUsernameExists(username string) (bool, error) { stmt := `SELECT 1 FROM users WHERE name = ?` return d.existsQuery(stmt, username) @@ -409,55 +452,14 @@ func (d DB) UpdateUserPasswordHash(userid int, newhash string) { util.Check(err, "changing user %d's description to %s", userid, newhash) } -func (d DB) DeleteUser(userid int) { - stmt := `DELETE FROM users WHERE id = ?` - _, err := d.Exec(stmt, userid) - util.Check(err, "deleting user %d", userid) -} - -func (d DB) AddPubkey(userid int, pubkey string) error { - ed := util.Describe("add pubkey") - // TODO (2022-02-03): the insertion order is wrong >.< - stmt := `INSERT INTO pubkeys (pubkey, userid) VALUES (?, ?)` - _, err := d.Exec(stmt, userid, pubkey) - if err = ed.Eout(err, "inserting record"); err != nil { - return err - } - return nil -} - -func (d DB) SetPubkey(userid int, pubkey string) error { - ed := util.Describe("set pubkey") - // TODO (2022-09-27): the insertion order is still wrong >.< - stmt := `UPDATE pubkeys SET pubkey = ? WHERE userid = ? ` - _, err := d.Exec(stmt, userid, pubkey) - if err = ed.Eout(err, "updating record"); err != nil { - return err - } - return nil -} - -func (d DB) GetPubkey(userid int) (pubkey string, err error) { - ed := util.Describe("get pubkey") - // due to a mishap in the query in AddPubkey the column `pubkey` contains the userid - // and the column `userid` contains the pubkey - // :')))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))) - // TODO (2022-02-03): when we have migration logic, fix this mishap - stmt := `SELECT userid from pubkeys where pubkey = ?` - err = d.db.QueryRow(stmt, userid).Scan(&pubkey) - err = ed.Eout(err, "query & scan") - return -} - -// TODO (2022-02-04): extend mrv verification code length and reuse nonce scheme to fix registration bug? -func (d DB) AddNonce(nonce string) error { - ed := util.Describe("add nonce") - stmt := `INSERT INTO nonces (nonce) VALUES (?)` - _, err := d.Exec(stmt, nonce) +func (d DB) GetSystemUserid() int { + ed := util.Describe("get system user id") + systemUserid, err := d.GetUserID(SYSTEM_USER_NAME) + // it should always exist if err != nil { - return ed.Eout(err, "insert nonce") + log.Fatalln(ed.Eout(err, "get system user id")) } - return nil + return systemUserid } func (d DB) AddRegistration(userid int, verificationLink string) error { @@ -474,3 +476,57 @@ func (d DB) AddRegistration(userid int, verificationLink string) error { } return nil } + +/* for moderation operations and queries, see database/moderation.go */ + +func (d DB) GetUsers(includeAdmin bool) []User { + ed := util.Describe("get users") + query := `SELECT u.name, u.id + FROM users u + %s + ORDER BY u.name + ` + + if includeAdmin { + query = fmt.Sprintf(query, "") // do nothing + } else { + query = fmt.Sprintf(query, "WHERE u.id NOT IN (select id from admins)") // do nothing + } + + stmt, err := d.db.Prepare(query) + ed.Check(err, "prep stmt") + defer stmt.Close() + + rows, err := stmt.Query() + util.Check(err, "run query") + defer rows.Close() + + var user User + var users []User + for rows.Next() { + if err := rows.Scan(&user.Name, &user.ID); err != nil { + ed.Check(err, "scanning loop") + } + users = append(users, user) + } + return users +} + +func (d DB) ResetPassword(userid int) (string, error) { + ed := util.Describe("reset password") + exists, err := d.CheckUserExists(userid) + if !exists { + return "", errors.New(fmt.Sprintf("reset password: userid %d did not exist", userid)) + } else if err != nil { + return "", fmt.Errorf("reset password encountered an error (%w)", err) + } + // generate new password for user and set it in the database + newPassword := crypto.GeneratePassword() + passwordHash, err := crypto.HashPassword(newPassword) + if err != nil { + return "", ed.Eout(err, "hash password") + } + d.UpdateUserPasswordHash(userid, passwordHash) + return newPassword, nil +} + diff --git a/database/migrations.go b/database/migrations.go @@ -0,0 +1,213 @@ +package database + +import ( + "encoding/base64" + "fmt" + "regexp" + "context" + "database/sql" + "strconv" + "log" + "errors" + "cerca/util" + "github.com/matthewhartstonge/argon2" +) +/* switched argon2 library to support 32 bit due to flaw in previous library. +* change occurred in commits: + 68a689612547ff83225f9a2727cf0c14dfbf7ceb + 27c6d5684b6b464b900889c4b8a4dbae232d6b68 + + migration of the password hashes from synacor's embedded salt format to + matthewartstonge's key-val embedded format + + migration details: + + the old format had the following default parameters: + * time = 1 + * memory = 64MiB + * threads = 4 + * keyLen = 32 + * saltLen = 16 bytes + * hashLen = 32 bytes? + * argonVersion = 13? + + + the new format uses the following parameters: + * TimeCost: 3, + * MemoryCost: 64 * 1024, // 2^(16) (64MiB of RAM) + * Parallelism: 4, + * SaltLength: 16, // 16 * 8 = 128-bits + * HashLength: 32, // 32 * 8 = 256-bits + * Mode: ModeArgon2id, + * Version: Version13, + + the diff: + * time was changed to 3 from 1 + * the version may or may not be the same (0x13) + + a regex for changing the values would be the following + old format example value: + $argon2id19$1,65536,4$111111111111111111111111111111111111111111111111111111111111111111 + old format was also encoding the salt and hash, not in base64 but in a slightly custom format (see var `encoding`) + + regex to grab values + \$argon2id19\$1,65536,4\$(\S{66}) + diff regex from old to new + $argon2id$v=19$m=65536,t=${1},p=4${passwordhash} + new format example value: + $argon2id$v=19$m=65536,t=3,p=4$222222222222222222222222222222222222222222222222222222222222222222 +*/ + +func Migration20240116_PwhashChange(filepath string) (finalErr error) { + d := InitDB(filepath) + ed := util.Describe("pwhash migration") + + // the encoding defined in the old hashing library for string representations + // https://github.com/synacor/argon2id/blob/18569dfc600ba1ba89278c3c4789ad81dcab5bfb/argon2id.go#L48 + var encoding = base64.NewEncoding("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").WithPadding(base64.NoPadding) + + // indices of the capture groups, index 0 is the matched string + const ( + _ = iota + TIME_INDEX + SALT_INDEX + HASH_INDEX + ) + + // regex to parse out: + // 1. time parameter + // 2. salt + // 3. hash + const oldArgonPattern = `^\$argon2id19\$(\d),65536,4\$(\S+)\$(\S+)$` + oldRegex, err := regexp.Compile(oldArgonPattern) + ed.Check(err, "failed to compile old argon encoding pattern") + // regex to confirm new records + const newArgonPattern = `^\$argon2id\$v=19\$m=65536,t=(\d),p=4\$(\S+)\$(\S+)$` + newRegex, err := regexp.Compile(newArgonPattern) + ed.Check(err, "failed to compile new argon encoding pattern") + + // always perform migrations in a single transaction + tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) + rollbackOnErr := func(incomingErr error) bool { + if incomingErr != nil { + _ = tx.Rollback() + log.Println(incomingErr, "\nrolling back") + finalErr = incomingErr + return true + } + return false + } + + // check table meta's schemaversion to see that it's empty (because i didn't set it initially X) + row := tx.QueryRow(`SELECT schemaversion FROM meta`) + placeholder := -1 + err = row.Scan(&placeholder) + // we *want* to have no rows + if err != nil && !errors.Is(err, sql.ErrNoRows) { + if rollbackOnErr(err) { return } + } + // in this migration, we should *not* have any schemaversion set + if placeholder > 0 { + if rollbackOnErr(errors.New("schemaversion existed! there's a high likelihood that this migration has already been performed - exiting")) { + return + } + } + + // alright onwards to the beesknees + // data struct to keep passwords and ids together - dont wanna mix things up now do we + type HashRecord struct { + id int + oldFormat string // the full encoded format of prev library. including salt and parameters, not just the hash + newFormat string // the full encoded format of new library. including salt and parameters, not just the hash + valid bool // only valid records will be updated (i.e. records whose format is confirmed by the the oldPattern regex) + } + + var records []HashRecord + // get all password hashes and the id of their row + query := `SELECT id, passwordhash FROM users` + rows, err := tx.Query(query) + if rollbackOnErr(err) { + return + } + + for rows.Next() { + var record HashRecord + err = rows.Scan(&record.id, &record.oldFormat) + if rollbackOnErr(err) { + return + } + if record.id == 0 { + if rollbackOnErr(errors.New("record id was not changed during scanning")) { + return + } + } + records = append(records, record) + } + + // make the requisite pattern changes to the password hash + config := argon2.MemoryConstrainedDefaults() + for i := range records { + // parse out the time, salt, and hash from the old record format + matches := oldRegex.FindAllStringSubmatch(records[i].oldFormat, -1) + if len(matches) > 0 { + time, err := strconv.Atoi(matches[0][TIME_INDEX]) + rollbackOnErr(err) + salt := matches[0][SALT_INDEX] + hash := matches[0][HASH_INDEX] + + // decode the old format's had a custom encoding t + // the correctly access the underlying buffers + saltBuf, err := encoding.DecodeString(salt) + util.Check(err, "decode salt using old format encoding") + hashBuf, err := encoding.DecodeString(hash) + util.Check(err, "decode hash using old format encoding") + + config.TimeCost = uint32(time) // note this change, to match old time cost (necessary!) + raw := argon2.Raw{Config:config, Salt: saltBuf, Hash: hashBuf} + // this is what we will store in the database instead + newFormatEncoded := raw.Encode() + ok := newRegex.Match(newFormatEncoded) + if !ok { + if rollbackOnErr(errors.New("newly formed format doesn't match regex for new pattern")) { + return + } + } + records[i].newFormat = string(newFormatEncoded) + records[i].valid = true + } else { + // parsing the old format failed, let's check to see if this happens to be a new record + // (if it is, we'll just ignore it. but if it's not we error out of here) + ok := newRegex.MatchString(records[i].oldFormat) + if !ok { + // can't parse with regex matching old format or the new format + if rollbackOnErr(errors.New(fmt.Sprintf("unknown record format: %s", records[i].oldFormat))) { + return + } + } + } + } + + fmt.Println(records) + + fmt.Println("parsed and re-encoded all valid records from the old to the new format. proceeding to update database records") + for _, record := range records { + if !record.valid { + continue + } + // update each row with the password hash in the new format + stmt, err := tx.Prepare("UPDATE users SET passwordhash = ? WHERE id = ?") + defer stmt.Close() + _, err = stmt.Exec(record.newFormat, record.id) + if rollbackOnErr(err) { + return + } + } + fmt.Println("all records were updated without any error") + // when everything is done and dudsted insert schemaversion and set its value to 1 + // _, err = tx.Exec(`INSERT INTO meta (schemaversion) VALUES (1)`) + // if rollbackOnErr(err) { + // return + // } + _ = tx.Commit() + return +} diff --git a/database/moderation.go b/database/moderation.go @@ -0,0 +1,512 @@ +package database +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "time" + + "cerca/util" + "cerca/constants" + + _ "github.com/mattn/go-sqlite3" + +) + +// there are a bunch of places that reference a user's id, so i don't want to break all of those +// +// i also want to avoid big invisible holes in a conversation's history +// +// remove user performs the following operation: +// 1. checks to see if the DELETED USER exists; otherwise create it and remember its id +// +// 2. if it exists, we swap out the userid for the DELETED_USER in tables: +// - table threads authorid +// - table posts authorid +// - table moderation_log actingid or recipientid +// +// the entry in registrations correlating to userid is removed +// if allowing deletion of post contents as well when removing account, +// userid should be used to get all posts from table posts and change the contents +// to say _deleted_ +func (d DB) RemoveUser(userid int) (finalErr error) { + ed := util.Describe("remove user") + // there is a single user we call the "deleted user", and we make sure this deleted user exists on startup + // they will take the place of the old user when they remove their account. + deletedUserID, err := d.GetUserID(DELETED_USER_NAME) + if err != nil { + log.Fatalln(ed.Eout(err, "get deleted user id")) + } + // create a transaction spanning all our removal-related ops + tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options? + rollbackOnErr:= func(incomingErr error) bool { + if incomingErr != nil { + _ = tx.Rollback() + log.Println(incomingErr, "rolling back") + finalErr = incomingErr + return true + } + return false + } + if rollbackOnErr(ed.Eout(err, "start transaction")) { + return + } + + // create prepared statements performing the required removal operations for tables that reference a userid as a + // foreign key: threads, posts, moderation_log, and registrations + threadsStmt, err := tx.Prepare("UPDATE threads SET authorid = ? WHERE authorid = ?") + defer threadsStmt.Close() + if rollbackOnErr(ed.Eout(err, "prepare threads stmt")) { + return + } + + postsStmt, err := tx.Prepare(`UPDATE posts SET content = "_deleted_", authorid = ? WHERE authorid = ?`) + defer postsStmt.Close() + if rollbackOnErr(ed.Eout(err, "prepare posts stmt")) { + return + } + + modlogStmt1, err := tx.Prepare("UPDATE moderation_log SET recipientid = ? WHERE recipientid = ?") + defer modlogStmt1.Close() + if rollbackOnErr(ed.Eout(err, "prepare modlog stmt #1")) { + return + } + + modlogStmt2, err := tx.Prepare("UPDATE moderation_log SET actingid = ? WHERE actingid = ?") + defer modlogStmt2.Close() + if rollbackOnErr(ed.Eout(err, "prepare modlog stmt #2")) { + return + } + + stmtReg, err := tx.Prepare("DELETE FROM registrations where userid = ?") + defer stmtReg.Close() + if rollbackOnErr(ed.Eout(err, "prepare registrations stmt")) { + return + } + + // and finally: removing the entry from the user's table itself + stmtUsers, err := tx.Prepare("DELETE FROM users where id = ?") + defer stmtUsers.Close() + if rollbackOnErr(ed.Eout(err, "prepare users stmt")) { + return + } + + _, err = threadsStmt.Exec(deletedUserID, userid) + if rollbackOnErr(ed.Eout(err, "exec threads stmt")) { + return + } + _, err = postsStmt.Exec(deletedUserID, userid) + if rollbackOnErr(ed.Eout(err, "exec posts stmt")) { + return + } + _, err = modlogStmt1.Exec(deletedUserID, userid) + if rollbackOnErr(ed.Eout(err, "exec modlog #1 stmt")) { + return + } + _, err = modlogStmt2.Exec(deletedUserID, userid) + if rollbackOnErr(ed.Eout(err, "exec modlog #2 stmt")) { + return + } + _, err = stmtReg.Exec(userid) + if rollbackOnErr(ed.Eout(err, "exec registration stmt")) { + return + } + _, err = stmtUsers.Exec(userid) + if rollbackOnErr(ed.Eout(err, "exec users stmt")) { + return + } + + err = tx.Commit() + ed.Check(err, "commit transaction") + finalErr = nil + return +} + +func (d DB) AddModerationLog(actingid, recipientid, action int) error { + ed := util.Describe("add moderation log") + t := time.Now() + // we have a recipient + var err error + if recipientid > 0 { + insert := `INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)` + _, err = d.Exec(insert, actingid, recipientid, action, t) + } else { + // we are not listing a recipient + insert := `INSERT INTO moderation_log (actingid, action, time) VALUES (?, ?, ?)` + _, err = d.Exec(insert, actingid, action, t) + } + if err = ed.Eout(err, "exec prepared statement"); err != nil { + return err + } + return nil +} + +type ModerationEntry struct { + ActingUsername, RecipientUsername, QuorumUsername string + QuorumDecision bool + Action int + Time time.Time +} + +func (d DB) GetModerationLogs () []ModerationEntry { + ed := util.Describe("moderation log") + query := `SELECT uact.name, urecp.name, uquorum.name, q.decision, m.action, m.time + FROM moderation_LOG m + + LEFT JOIN users uact ON uact.id = m.actingid + LEFT JOIN users urecp ON urecp.id = m.recipientid + + LEFT JOIN quorum_decisions q ON q.modlogid = m.id + LEFT JOIN users uquorum ON uquorum.id = q.userid + + ORDER BY time DESC` + + stmt, err := d.db.Prepare(query) + defer stmt.Close() + ed.Check(err, "prep stmt") + + rows, err := stmt.Query() + defer rows.Close() + util.Check(err, "run query") + + var logs []ModerationEntry + for rows.Next() { + var entry ModerationEntry + var actingUsername, recipientUsername, quorumUsername sql.NullString + var quorumDecision sql.NullBool + if err := rows.Scan(&actingUsername, &recipientUsername, &quorumUsername, &quorumDecision, &entry.Action, &entry.Time); err != nil { + ed.Check(err, "scanning loop") + } + if actingUsername.Valid { + entry.ActingUsername = actingUsername.String + } + if recipientUsername.Valid { + entry.RecipientUsername = recipientUsername.String + } + if quorumUsername.Valid { + entry.QuorumUsername = quorumUsername.String + } + if quorumDecision.Valid { + entry.QuorumDecision = quorumDecision.Bool + } + logs = append(logs, entry) + } + return logs +} + +func (d DB) ProposeModerationAction(proposerid, recipientid, action int) (finalErr error) { + ed := util.Describe("propose mod action") + + t := time.Now() + tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) + ed.Check(err, "open transaction") + + rollbackOnErr:= func(incomingErr error) bool { + if incomingErr != nil { + _ = tx.Rollback() + log.Println(incomingErr, "rolling back") + finalErr = incomingErr + return true + } + return false + } + + // start tx + propRecipientId := -1 + // there should only be one pending proposal of each type for any given recipient + // so let's check to make sure that's true! + stmt, err := tx.Prepare("SELECT recipientid FROM moderation_proposals WHERE action = ?") + defer stmt.Close() + err = stmt.QueryRow(action).Scan(&propRecipientId) + if err == nil && propRecipientId != -1 { + finalErr = tx.Commit() + return + } + // there was no pending proposal of the proposed action for recipient - onwards! + + // add the proposal + stmt, err = tx.Prepare("INSERT INTO moderation_proposals (proposerid, recipientid, time, action) VALUES (?, ?, ?, ?)") + defer stmt.Close() + if rollbackOnErr(ed.Eout(err, "prepare proposal stmt")) { + return + } + _, err = stmt.Exec(proposerid, recipientid, t, action) + if rollbackOnErr(ed.Eout(err, "insert into proposals table")) { + return + } + + // TODO (2023-12-18): hmm how do we do this properly now? only have one constant per action + // {demote, make admin, remove user} but vary translations for these three depending on if there is also a decision or not? + + // add moderation log that user x proposed action y for recipient z + stmt, err = tx.Prepare(`INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`) + defer stmt.Close() + if rollbackOnErr(ed.Eout(err, "prepare modlog stmt")) { + return + } + _, err = stmt.Exec(proposerid, recipientid, action, t) + if rollbackOnErr(ed.Eout(err, "insert into modlog")) { + return + } + + err = tx.Commit() + ed.Check(err, "commit transaction") + return +} + +type ModProposal struct { + ActingUsername, RecipientUsername string + ActingID, RecipientID int + ProposalID, Action int + Time time.Time +} + +func (d DB) GetProposedActions() []ModProposal { + ed := util.Describe("get moderation proposals") + stmt, err := d.db.Prepare(`SELECT mp.id, proposerid, up.name, recipientid, ur.name, action, mp.time + FROM moderation_proposals mp + INNER JOIN users up on mp.proposerid = up.id + INNER JOIN users ur on mp.recipientid = ur.id + ORDER BY time DESC + ;`) + defer stmt.Close() + ed.Check(err, "prepare stmt") + rows, err := stmt.Query() + ed.Check(err, "perform query") + defer rows.Close() + var proposals []ModProposal + for rows.Next() { + var prop ModProposal + if err = rows.Scan(&prop.ProposalID, &prop.ActingID, &prop.ActingUsername, &prop.RecipientID, &prop.RecipientUsername, &prop.Action, &prop.Time); err != nil { + ed.Check(err, "error scanning in row data") + } + proposals = append(proposals, prop) + } + return proposals +} + +// finalize a proposal by either confirming or vetoing it, logging the requisite information and then finally executing +// the proposed action itself +func (d DB) FinalizeProposedAction(proposalid, adminid int, decision bool) (finalErr error) { + ed := util.Describe("finalize proposed mod action") + + t := time.Now() + tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) + ed.Check(err, "open transaction") + + rollbackOnErr:= func(incomingErr error) bool { + if incomingErr != nil { + _ = tx.Rollback() + log.Println(incomingErr, "rolling back") + finalErr = incomingErr + return true + } + return false + } + + /* start tx */ + // make sure the proposal is still there (i.e. nobody has beat us to acting on it yet) + stmt, err := tx.Prepare("SELECT 1 FROM moderation_proposals WHERE id = ?") + defer stmt.Close() + if rollbackOnErr(ed.Eout(err, "prepare proposal existence stmt")) { + return + } + existence := -1 + err = stmt.QueryRow(proposalid).Scan(&existence) + // proposal id did not exist (it was probably already acted on!) + if err != nil { + _ = tx.Commit() + return + } + // retrieve the proposal & populate with our dramatis personae + var proposerid, recipientid, proposalAction int + var proposalDate time.Time + stmt, err = tx.Prepare(`SELECT proposerid, recipientid, action, time from moderation_proposals WHERE id = ?`) + defer stmt.Close() + err = stmt.QueryRow(proposalid).Scan(&proposerid, &recipientid, &proposalAction, &proposalDate) + if rollbackOnErr(ed.Eout(err, "retrieve proposal vals")) { + return + } + + timeSelfConfirmOK := proposalDate.Add(constants.PROPOSAL_SELF_CONFIRMATION_WAIT) + // TODO (2024-01-07): render err message in admin view? + // self confirms are not allowed at this point in time, exit early without performing any changes + if decision == constants.PROPOSAL_CONFIRM && !time.Now().After(timeSelfConfirmOK) { + err = tx.Commit() + ed.Check(err, "commit transaction") + finalErr = nil + return + } + + // convert proposed action (semantically different for the sake of logs) from the finalized action + var action int + switch proposalAction { + case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: + action = constants.MODLOG_ADMIN_DEMOTE + case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: + action = constants.MODLOG_REMOVE_USER + case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: + action = constants.MODLOG_ADMIN_MAKE + default: + ed.Check(errors.New("unknown proposal action"), "convertin proposalAction into action") + } + + // remove proposal from proposal table as it has been executed as desired + stmt, err = tx.Prepare("DELETE FROM moderation_proposals WHERE id = ?") + defer stmt.Close() + if rollbackOnErr(ed.Eout(err, "prepare proposal removal stmt")) { + return + } + _, err = stmt.Exec(proposalid) + if rollbackOnErr(ed.Eout(err, "remove proposal from table")) { + return + } + + // add moderation log + stmt, err = tx.Prepare(`INSERT INTO moderation_log (actingid, recipientid, action, time) VALUES (?, ?, ?, ?)`) + defer stmt.Close() + if rollbackOnErr(ed.Eout(err, "prepare modlog stmt")) { + return + } + // the admin who proposed the action will be logged as the one performing it + // get the modlog so we can reference it in the quorum_decisions table. this will be used to augment the moderation + // log view with quorum info + result, err := stmt.Exec(proposerid, recipientid, action, t) + if rollbackOnErr(ed.Eout(err, "insert into modlog")) { + return + } + modlogid, err := result.LastInsertId() + if rollbackOnErr(ed.Eout(err, "get last insert id")) { + return + } + + // update the quorum decisions table so that we can use its info to augment the moderation log view + stmt, err = tx.Prepare(`INSERT INTO quorum_decisions (userid, decision, modlogid) VALUES (?, ?, ?)`) + defer stmt.Close() + if rollbackOnErr(ed.Eout(err, "prepare quorum insertion stmt")) { + return + } + // decision = confirm or veto => values true or false + _, err = stmt.Exec(adminid, decision, modlogid) + if rollbackOnErr(ed.Eout(err, "execute quorum insertion")) { + return + } + + err = tx.Commit() + ed.Check(err, "commit transaction") + + // the decision was to veto the proposal: there's nothing more to do! except return outta this function ofc ofc + if decision == constants.PROPOSAL_VETO { + return + } + // perform the actual action; would be preferable to do this in the transaction somehow + // but hell no am i copying in those bits here X) + switch proposalAction { + case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: + err = d.DemoteAdmin(recipientid) + ed.Check(err, "remove user", recipientid) + case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: + err = d.RemoveUser(recipientid) + ed.Check(err, "remove user", recipientid) + case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: + d.AddAdmin(recipientid) + ed.Check(err, "add admin", recipientid) + } + return +} + +type User struct { + Name string + ID int +} + +func (d DB) AddAdmin(userid int) error { + ed := util.Describe("add admin") + // make sure the id exists + exists, err := d.CheckUserExists(userid) + if !exists { + return errors.New(fmt.Sprintf("add admin: userid %d did not exist", userid)) + } + if err != nil { + return ed.Eout(err, "CheckUserExists had an error") + } + isAdminAlready, err := d.IsUserAdmin(userid) + if isAdminAlready { + return errors.New(fmt.Sprintf("userid %d was already an admin", userid)) + } + if err != nil { + // some kind of error, let's bubble it up + return ed.Eout(err, "IsUserAdmin") + } + // insert into table, we gots ourselves a new sheriff in town [|:D + stmt := `INSERT INTO admins (id) VALUES (?)` + _, err = d.db.Exec(stmt, userid) + if err != nil { + return ed.Eout(err, "inserting new admin") + } + return nil +} + +func (d DB) DemoteAdmin(userid int) error { + ed := util.Describe("demote admin") + // make sure the id exists + exists, err := d.CheckUserExists(userid) + if !exists { + return errors.New(fmt.Sprintf("demote admin: userid %d did not exist", userid)) + } + if err != nil { + return ed.Eout(err, "CheckUserExists had an error") + } + isAdmin, err := d.IsUserAdmin(userid) + if !isAdmin { + return errors.New(fmt.Sprintf("demote admin: userid %d was not an admin", userid)) + } + if err != nil { + // some kind of error, let's bubble it up + return ed.Eout(err, "IsUserAdmin") + } + // all checks are done: perform the removal + stmt := `DELETE FROM admins WHERE id = ?` + _, err = d.db.Exec(stmt, userid) + if err != nil { + return ed.Eout(err, "inserting new admin") + } + return nil +} + +func (d DB) IsUserAdmin (userid int) (bool, error) { + stmt := `SELECT 1 FROM admins WHERE id = ?` + return d.existsQuery(stmt, userid) +} + +func (d DB) QuorumActivated () bool { + admins := d.GetAdmins() + return len(admins) >= 2 +} + +func (d DB) GetAdmins() []User { + ed := util.Describe("get admins") + query := `SELECT u.name, a.id + FROM users u + INNER JOIN admins a ON u.id = a.id + ORDER BY u.name + ` + stmt, err := d.db.Prepare(query) + defer stmt.Close() + ed.Check(err, "prep stmt") + + rows, err := stmt.Query() + defer rows.Close() + util.Check(err, "run query") + + var user User + var admins []User + for rows.Next() { + if err := rows.Scan(&user.Name, &user.ID); err != nil { + ed.Check(err, "scanning loop") + } + admins = append(admins, user) + } + return admins +} diff --git a/go.mod b/go.mod @@ -8,11 +8,11 @@ require ( github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df github.com/gorilla/sessions v1.2.1 github.com/komkom/toml v0.1.2 - github.com/mattn/go-sqlite3 v1.14.9 - github.com/microcosm-cc/bluemonday v1.0.17 + github.com/matthewhartstonge/argon2 v1.0.0 + github.com/mattn/go-sqlite3 v1.14.19 + github.com/microcosm-cc/bluemonday v1.0.26 github.com/stretchr/testify v1.7.0 // indirect github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b - golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e golang.org/x/time v0.3.0 ) diff --git a/go.sum b/go.sum @@ -1,3 +1,6 @@ +github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/carlmjohnson/requests v0.22.1 h1:YoifpEbpJW4LPRX/+0dJe3vTLducEE9Ib10k6lElIUM= @@ -22,10 +25,16 @@ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/komkom/toml v0.1.2 h1:SexwnY3JOR0kU9F/xxw/129BPCvuKi6/E89PZ4kSSBo= github.com/komkom/toml v0.1.2/go.mod h1:cgnL/ntRyMHaZuDy9wREJHWY1Cb2HEINK7U0YhpcTa8= +github.com/matthewhartstonge/argon2 v1.0.0 h1:e65fkae6O8Na6YTy2HAccUbXR+GQHOnpQxeWGqWCRIw= +github.com/matthewhartstonge/argon2 v1.0.0/go.mod h1:Fm4FHZxdxCM6hg21Jkz3YZVKnU7VnTlqDQ3ghS/Myok= github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= +github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= +github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -45,14 +54,19 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e h1:SkwG94eNiiYJhbeDE018Grw09HIN/KB9NlRmZsrzfWs= golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/image v0.0.0-20220321031419-a8550c1d254a/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -60,9 +74,17 @@ golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d h1:62NvYBuaanGXR2ZOfwDFkhhl6 golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -73,17 +95,33 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= diff --git a/html/admin-add-user.html b/html/admin-add-user.html @@ -0,0 +1,21 @@ +{{ template "head" . }} +<main> + <h1> {{ .Title }}</h1> + <p>{{ "AdminAddUserExplanation" | translate }}</p> + + <form method="post"> + <label for="username">{{ "Username" | translate | capitalize }}:</label> + <input type="text" required id="username" name="username"> + <div> + <input type="submit" value='{{ "Register" | translate | capitalize }}'> + </div> + </form> + + {{ if .Data.ErrorMessage }} + <div> + <p><b> {{ .Data.ErrorMessage }} </b></p> + </div> + {{ end }} + +</main> +{{ template "footer" . }} diff --git a/html/admin.html b/html/admin.html @@ -0,0 +1,103 @@ +{{ template "head" . }} +<main> + <h1>{{ .Title }}</h1> + <section> + <form method="GET" id="add-user" action="/add-user"></form> + <p> + <form method="POST" id="demote-self" action="/demote-admin"> + <input type="hidden" name="userid" value="{{ .LoggedInID }}"> + </form> + <p> + {{ "AdminAddNewUserQuestion" | translate }} <button form="add-user" type="submit"> {{ "AdminAddNewUser" | translate }}</button>. + </p> + <p> + {{ "AdminStepDownExplanation" | translate }} <button form="demote-self" type="submit">{{ "AdminStepDown" | translate }}</button>. + </p> + <p> + {{ "AdminViewPastActions" | translate }} <a href="/moderations">{{ "ModerationLog" | translate }}</a>. + </p> + </section> + {{ if .LoggedIn }} + {{ $userID := .LoggedInID }} + <section> + <h2> {{ "Admins" | translate | capitalize }} </h2> + {{ if len .Data.Admins | eq 0 }} + <p> {{ "AdminNoAdmins" | translate }} </p> + {{ else }} + <table> + {{ range $index, $user := .Data.Admins }} + <tr> + <form method="POST" id="demote-admin-{{$user.ID}}" action="/demote-admin"> + <input type="hidden" name="userid" value="{{ $user.ID }}"> + </form> + <td>{{ $user.Name }} ({{ $user.ID }}) </td> + <td> + {{ if eq $userID $user.ID }} <i>({{ "AdminYou" | translate }})</i> + {{ else }}<button type="submit" form="demote-admin-{{$user.ID}}">{{ "AdminDemote" | translate }}</button>{{ end }} + </td> + </tr> + {{ end }} + </table> + {{ end }} + </section> + <section> + <h2> {{ "PendingProposals" | translate }} </h2> + <p> {{ "AdminPendingExplanation" | translate | tohtml }}</p> + {{ if len .Data.Proposals | eq 0}} + <p><i>{{ "AdminNoPendingProposals" | translate }}</i></p> + {{ else }} + <table> + <tr> + <th>{{ "Proposal" | translate }}</th> + <th colspan="3">{{ "AdminSelfProposalsBecomeValid" | translate }}</th> + </tr> + {{ range $index, $proposal := .Data.Proposals }} + <tr> + <form method="POST" id="confirm-{{$proposal.ID}}" action="/proposal-confirm"> + <input type="hidden" name="proposalid" value="{{ $proposal.ID }}"> + </form> + <form method="POST" id="veto-{{$proposal.ID}}" action="/proposal-veto"> + <input type="hidden" name="proposalid" value="{{ $proposal.ID }}"> + </form> + <td> {{ $proposal.Action | tohtml }} </td> + <td> {{ $proposal.Time | formatDateTime }} </td> + <td><button type="submit" form="veto-{{$proposal.ID}}">{{ "AdminVeto" | translate }}</button></td> + <td><button {{ if not $proposal.TimePassed }} disabled title='{{ "AdminSelfConfirmationHover" | translate}}' {{ end }} type="submit" form="confirm-{{$proposal.ID}}">{{"AdminConfirm" | translate}}</button></td> + </tr> + {{ end }} + </table> + {{ end }} + + <section> + <h2> {{ "AdminUsers" | translate }} </h2> + {{ if len .Data.Users | eq 0 }} + <p> {{ "AdminNoUsers" | translate }} </p> + {{ else }} + <table> + {{ range $index, $user := .Data.Users }} + {{ if and (ne $user.Name "CERCA_CMD") (ne $user.Name "deleted user") }} + <form method="POST"> + <input type="hidden" name="userid" value="{{$user.ID}}"> + <tr> + <td>{{ $user.Name }} ({{ $user.ID }})</td> + <td> + <select name="admin-action" action="/admin/" id="select-{{$user.ID}}"> + <option selected value="reset-password">{{ "PasswordReset" | translate | capitalize }}</option> + <option value="remove-account">{{ "RemoveAccount" | translate | capitalize }}</option> + <option value="make-admin">{{ "AdminMakeAdmin" | translate }}</option> + </select> + </td> + <td> + <button type="submit">{{ "Submit" | translate }}</button> + </td> + </tr> + {{ end }} + </form> + {{ end }} + </table> + {{ end }} + </section> + + {{ end }} +</main> +{{ template "footer" . }} diff --git a/html/admins-list.html b/html/admins-list.html @@ -0,0 +1,22 @@ +{{ template "head" . }} +<main> + <h1>{{ .Title }}</h1> + {{ if .LoggedIn }} + <section> + <p>{{ "AdminViewPastActions" | translate }} <a href="/moderations">{{ "ModerationLog" | translate }}</a>.</p> + {{ if len .Data.Admins | eq 0 }} + <p> {{ "AdminNoAdmins" | translate }} </p> + {{ else }} + <p>{{ "AdminForumHasAdmins" | translate }}:</p> + <ul> + {{ range $index, $user := .Data.Admins }} + <li> {{ $user.Name }} </li> + {{ end }} + </ul> + </section> + {{ end }} + {{ else }} + <p> {{ "AdminOnlyLoggedInMayView" | translate }} </p> + {{ end }} +</main> +{{ template "footer" . }} diff --git a/html/change-password-success.html b/html/change-password-success.html @@ -2,11 +2,5 @@ <h1>{{ "ChangePassword" | translate | capitalize }}</h1> <p>{{ "PasswordResetSuccessMessage" | translate }}</p> -{{ if ne .Data.Keypair "" }} -<p> {{ "RegisterKeypairWarning" | translate }}.</p> -<pre class="selectable"> -{{ .Data.Keypair }} -</pre> -{{ end }} <p> {{ "RegisterLinkMessage" | translate }} <a href="/">{{ "Index" | translate }}</a>.</p> {{ template "footer" . }} diff --git a/html/change-password.html b/html/change-password.html @@ -12,10 +12,6 @@ <div><small id="password-help">{{ "PasswordMin" | translate }}.</small></div> </div> <div> - <input type="checkbox" value="true" name ="reset-keypair" id="reset-keypair"> - <label for="reset-keypair" style="display: inline-block;">{{ "GenerateNewKeypair" | translate }}</label> - </div> - <div> <input type="submit" value="Submit"> </div> </form> diff --git a/html/edit-post.html b/html/edit-post.html @@ -0,0 +1,17 @@ +{{ template "head" . }} +<main> + <h1>{{ "PostEdit" | translate }}</h1> + <article>{{.Data.Content | markup }} + </article> + <form method="POST"> + <div class="post-container" > + <label class="visually-hidden" for="content">{{ "Content" | translate }}:</label> + <textarea required name="content" id="content" placeholder='{{ "TextareaPlaceholder" | translate }}'>{{.Data.Content}}</textarea> + <button type="submit">{{ "Save" | translate }}</button> + </div> + <div style="margin-top: 1rem;"> + <a style="font-style: italic;" href="/thread/{{.Data.ThreadID}}/#{{.Data.ID}}">{{ "GoBackToTheThread" | translate }}</a> + </div> + </form> +</main> +{{ template "footer" . }} diff --git a/html/head.html b/html/head.html @@ -30,9 +30,11 @@ font-size: 1rem; } button { - text-decoration: underline; cursor: pointer; } + button, select { + margin-bottom: 0; + } #logo { width: 48px; height: 48px; @@ -194,6 +196,9 @@ {{ if .QuickNav }} <li><a href="#bottom">{{ "Bottom" | translate }}</a></li> {{end}} + {{ if .IsAdmin }} + <li><a href="/admin">admin</a></li> + {{end}} <li><a href="/about">{{ "About" | translate }}</a></li> {{ if .HasRSS }} <li><a href="/rss.xml">rss</a></li> diff --git a/html/index.html b/html/index.html @@ -1,8 +1,11 @@ {{ template "head" . }} <main> - {{ range $index, $thread := .Data.Threads }} - <h2><a href="{{$thread.Slug}}">{{ $thread.Title }}</a></h2> - {{ end }} + {{ if len .Data.Threads | eq 0 }} + <p> There are currently no threads. </p> + {{ end }} + {{ range $index, $thread := .Data.Threads }} + <h2><a href="{{$thread.Slug}}">{{ $thread.Title }}</a></h2> + {{ end }} </main> {{ if .LoggedIn }} <aside> diff --git a/html/moderation-log.html b/html/moderation-log.html @@ -0,0 +1,32 @@ +{{ template "head" . }} +<main> + <h1>{{ .Title }}</h1> + {{ if .LoggedIn }} + <section> + {{ if len .Data.Log | eq 100 }} + <p> {{ "ModLogNoActions" | translate }} </p> + {{ else }} + <p>{{ "ModLogExplanation" | translate }} {{ if .IsAdmin }} {{ "ModLogExplanationAdmin" | translate }} {{ end }} </p> + <style> + section ul { padding-left: 0; } + section ul li { + list-style-type: none; + border: darkred solid 1px; + } + section ul > li:nth-of-type(2n) { + color: wheat; + background: darkred; + } + </style> + <ul> + {{ range $index, $entry := .Data.Log }} + <li> {{ $entry | tohtml }} </li> + {{ end }} + </ul> + </section> + {{ end }} + {{ else }} + <p> {{ "ModLogOnlyLoggedInMayView" | translate }} </p> + {{ end }} +</main> +{{ template "footer" . }} diff --git a/html/password-reset.html b/html/password-reset.html @@ -1,4 +1,5 @@ {{ template "head" . }} +<p>This page is currently being reconstructed; if you can't log in, contact admin for help to reset your password :)</p> <p>{{ "PasswordResetDescription" | translate }}</p> <p>{{ "PasswordResetUsernameQuestion" | translate }}</p> {{ if eq .Data.Action "/reset/generate" }} @@ -6,7 +7,6 @@ <label type="text" for="username">{{ "Username" | translate | capitalize }}:</label> <input required id="username" name="username"> <div> - <input type="submit" value='{{ "GeneratePayload" | translate | capitalize }}'> </div> </form> {{ end }} @@ -18,22 +18,6 @@ <pre style="user-select: all;"> <code>{{ .Data.Payload }}</code> </pre> -<p> {{ "PasswordResetFollowToolInstructions" | translate | tohtml }}</p> -<details> - <summary>{{ "ToolInstructions" | translate | capitalize }}</summary> - {{ "PasswordResetToolInstructions" | translate | tohtml }} -</details> -<form method="post" action="{{.Data.Action}}"> - <input type="hidden" required id="username" name="username" value="{{ .Data.Username }}"> - <input type="hidden" required id="payload" name="payload" value="{{ .Data.Payload }}"> - <label for="proof">{{ "Proof" | translate | capitalize }}</label> - <input type="text" required id="proof" name="proof"> - <label for="password">{{ "NewPassword" | translate | capitalize }}</label> - <input type="password" minlength="9" required id="password" name="password" aria-describedby="password-help"> - <div> - <input type="submit" value='{{ "ChangePassword" | translate | capitalize }}'> - </div> -</form> {{ end }} {{ template "footer" . }} diff --git a/html/register-success.html b/html/register-success.html @@ -2,14 +2,6 @@ <main> <h1>{{ "Register" | translate | capitalize }}</h1> <p>{{ "RegisterHTMLMessage" | translate | tohtml }}</p> - <p>{{ "RegisterKeypairExplanationStart" | translate | tohtml }}</p> - <p>{{ "RegisterKeypairExplanationEnd" | translate | tohtml }}</p> - <p><i><b>{{ "RegisterKeypairWarning" | translate }}</i></b></p> - <code> - <pre class="selectable"> - {{ .Data.Keypair }} - </pre> - </code> </main> {{ template "footer" . }} diff --git a/html/register.html b/html/register.html @@ -30,7 +30,9 @@ </div> </div> {{ end }} + <div> <input type="submit" value='{{ "Register" | translate | capitalize }}'> + </div> </form> {{ if .Data.ErrorMessage }} diff --git a/html/thread.html b/html/thread.html @@ -14,6 +14,7 @@ <input type="hidden" name="thread" value="{{ $threadURL }}"> </form> </span> + <span style="float: right; margin-right:0.5rem"><a href="/post/edit/{{ $post.ID }}">edit</a></span> {{ end }} <span class="visually-hidden">{{ "Author" | translate }}:</span> <span><b>{{ $post.Author }}</b> @@ -21,11 +22,14 @@ </span> <a href="#{{ $post.ID }}"> <span style="margin-left: 0.5rem;"> - <time title="{{ $post.Publish | formatDateTime }}" datetime="{{ $post.Publish | formatDate }}">{{ $post.Publish | formatDateRelative }}</time> + <time title="{{ $post.Publish | formatDateTime }}" datetime="{{ $post.Publish | formatDate }}">{{ $post.Publish | formatDateRelative }}</time></span></a> + {{ if $post.LastEdit.Valid }} + <span style="cursor: pointer;"> + <time title="{{ "EditedAt" | translate }} {{ $post.LastEdit.Time | formatDateTime }}" datetime="{{ $post.LastEdit.Time | formatDate }}">*</time> </span> - </a> + {{ end }} </section> - {{ $post.Content }} + {{ $post.Content | markup }} </article> {{ end }} {{ if .LoggedIn }} diff --git a/i18n/i18n.go b/i18n/i18n.go @@ -27,6 +27,63 @@ var English = map[string]string{ "SortRecentPosts": "recent posts", "SortRecentThreads": "most recent threads", + "modlogResetPassword": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> reset a user's password`, + "modlogResetPasswordAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> reset <b> {{ .Data.RecipientUsername}}</b>'s password`, + "modlogRemoveUser": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> removed a user's account`, + "modlogMakeAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> made <b> {{ .Data.RecipientUsername}}</b> an admin`, + "modlogAddUser": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> manually registered an account for a new user`, + "modlogAddUserAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> manually registered an account for <b> {{ .Data.RecipientUsername }}</b>`, + "modlogDemoteAdmin": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> demoted <b> + {{ if eq .Data.ActingUsername .Data.RecipientUsername }} themselves + {{ else }} {{ .Data.RecipientUsername}} {{ end }}</b> from admin back to normal user`, + "modlogXProposedY": `<code>{{ .Data.Time }}</code> <b>{{ .Data.ActingUsername }}</b> proposed: {{ .Data.Action }}`, + "modlogProposalMakeAdmin": `Make <b> {{ .Data.RecipientUsername}}</b> admin`, + "modlogProposalDemoteAdmin": `Demote <b> {{ .Data.RecipientUsername}}</b> from role admin`, + "modlogProposalRemoveUser": `Remove user <b> {{ .Data.RecipientUsername }} </b>`, + "modlogConfirm": "{{ .Data.Action }} <i>confirmed by {{ .Data.ActingUsername }}</i>", + "modlogVeto": "<s>{{ .Data.Action }}</s> <i>vetoed by {{ .Data.ActingUsername }}</i>", + + + + "Admins": "admins", + "AdminVeto": "Veto", + "AdminConfirm": "Confirm", + "AdminForumAdministration": "Forum Administration", + "AdminYou": "you!", + "AdminUsers": "Users", + "AdminNoAdmins": "There are no admins", + "AdminNoUsers": "There are no other users", + "AdminNoPendingProposals": "There are no pending proposals", + "AdminAddNewUser": "Add new user", + "AdminAddNewUserQuestion": "Does someone wish attendence? You can ", + "AdminStepDown": "Step down", + "AdminStepDownExplanation": "If you want to stop being an admin, you can", + "AdminViewPastActions": "View past actions in the", + "ModerationLog": "moderation log", + "AdminDemote": "Demote", + "DeletedUser": "deleted user", + "RemoveAccount": "remove account", + "AdminMakeAdmin": "Make admin", + "Submit": "Submit", + "AdminSelfConfirmationsHover": "a week must pass before self-confirmations are ok", + "Proposal": "Proposal", + "PendingProposals": "Pending Proposals", + "AdminSelfProposalsBecomeValid": "Date self-proposals become valid", + "AdminPendingExplanation": `Two admins are required for <i>making a user an admin</i>, <i>demoting an existing + admin</i>, or <i>removing a user</i>. The first proposes the action, the second confirms + (or vetos) it. If enough time elapses without a veto, the proposer may confirm their own + proposal.`, + + "AdminAddUserExplanation": "Register a new user account. After registering the account you will be given a generated password and instructions to pass onto the user.", + "AdminForumHasAdmins": "The forum currently has the following admins", + "AdminOnlyLoggedInMayView": "Only logged in users may view the forum's admins.", + "AdminPasswordSuccessInstructions": `Instructions: %s's password was set to: %s. After logging in, please change your password by going to /reset`, + + "ModLogNoActions": "there are no logged moderation actions", + "ModLogExplanation": `This resource lists the moderation actions taken by the forum's administrators.`, + "ModLogExplanationAdmin": `You are viewing this page as an admin, you will see slightly more details.`, + "ModLogOnlyLoggedInMayView": "Only logged in users may view the moderation log.", + "LoginNoAccount": "Don't have an account yet? <a href='/register'>Register</a> one.", "LoginFailure": "<b>Failed login attempt:</b> incorrect password, wrong username, or a non-existent user.", "LoginAlreadyLoggedIn": `You are already logged in. Would you like to <a href="/logout">log out</a>?`, @@ -34,8 +91,7 @@ var English = map[string]string{ "Username": "username", "Current": "current", "New": "new", - "GenerateNewKeypair": "I also want to generate a new keypair", - "ChangePasswordDescription": "Use this page to change your password. If needed, you can also regenerate your password reset keypair—used to reset a forgotten password without admin help.", + "ChangePasswordDescription": "Use this page to change your password.", "Password": "password", "PasswordMin": "Must be at least 9 characters long", "PasswordForgot": "Forgot your password?", @@ -44,6 +100,7 @@ var English = map[string]string{ "ThreadNew": "new thread", "ThreadThe": "the thread", "Index": "index", + "GoBackToTheThread": "Go back to the thread", "ThreadCreate": "Create thread", "Title": "Title", @@ -52,7 +109,6 @@ var English = map[string]string{ "TextareaPlaceholder": "Tabula rasa", "PasswordReset": "reset password", - "PasswordResetMessage": "You are logged in, log out to reset password using proof", "PasswordResetSuccess": "Reset password—success!", "PasswordResetSuccessMessage": "You reset your password!", "PasswordResetSuccessLinkMessage": "Give it a try and", @@ -62,6 +118,10 @@ var English = map[string]string{ "RegisterSuccess": "registered successfully", "ErrUnaccepted": "Unaccepted request", + "ErrGeneric401": "Unauthorized", + "ErrGeneric401Message": "You do not have permissions to perform this action.", + "ErrEdit404": "Post not found", + "ErrEdit404Message": "This post cannot be found for editing", "ErrThread404": "Thread not found", "ErrThread404Message": "The thread does not exist (anymore?)", "ErrGeneric404": "Page not found", @@ -71,13 +131,17 @@ var English = map[string]string{ "NewThreadLinkMessage": "If you are a member,", "NewThreadCreateError": "Error creating thread", "NewThreadCreateErrorMessage": "There was a database error when creating the thread, apologies.", + "PostEdit": "Post preview", "AriaPostMeta": "Post meta", "AriaDeletePost": "Delete this post", "AriaRespondIntoThread": "Respond into this thread", "PromptDeleteQuestion": "Delete post for all posterity?", "Delete": "delete", + "Edit": "edit", + "EditedAt": "edited at", "Post": "post", + "Save": "Save", "Author": "Author", "Responded": "responded", "YourAnswer": "Your answer", @@ -86,9 +150,6 @@ var English = map[string]string{ "ThreadStartNew": "Start a new thread", "RegisterHTMLMessage": `You now have an account! Welcome. Visit the <a href="/">index</a> to read and reply to threads, or start a new one.`, - "RegisterKeypairExplanationStart": `There's just one more thing: <b>save the key displayed below</b>. It is a <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">keypair</a> describing your forum identity, with a private part that only you know; the forum only stores the public portion.`, - "RegisterViewKeypairExplanationEnd": `With this keypair you will be able to reset your account if you ever lose your password—and without having to share your email (or require email infrastructure on the forum's part).`, - "RegisterKeypairWarning": "This keypair will only be displayed once", "RegisterVerificationCode": "Your verification code is", "RegisterVerificationInstructionsTitle": "Verification instructions", @@ -96,22 +157,6 @@ var English = map[string]string{ "RegisterConductCodeBoxOne": `I have refreshed my memory of the <a target="_blank" href="{{ .Data.Link }}">{{ .Data.Name }} Code of Conduct</a>`, "RegisterConductCodeBoxTwo": `Yes, I have actually <a target="_blank" href="{{ .Data.Link }}">read it</a>`, - "PasswordResetDescription": "On this page we'll go through a few steps to securely reset your password—without resorting to any emails!", - "PasswordResetUsernameQuestion": "First up: what was your username?", - "PasswordResetCopyPayload": `Now, first copy the snippet (aka <i>proof payload</i>) below`, - "PasswordResetFollowToolInstructions": `Follow the <b>tool instructions</b> to finalize the password reset.`, - "ToolInstructions": `tool instructions`, - "PasswordResetToolInstructions": fmt.Sprintf(` - <ul> - <li><a href="%s">Download the tool</a></li> - <li>Run as:<br><code>pwtool --payload &lt;proof payload from above&gt; --keypair &lt;path to file with yr keypair from registration&gt;</code> - </li> - <li>Copy the generated proof and paste below</li> - <li>(Remember to save your password :)</li> - </ul> - `, toolURL), - "GeneratePayload": "generate payload", - "Proof": "proof", "NewPassword": "new password", "ChangePassword": "change password", } @@ -140,8 +185,7 @@ var Swedish = map[string]string{ "Username": "användarnamn", "Current": "nuvarande", "New": "nytt", - "GenerateNewKeypair": "Jag vill också generera ett nytt nyckelpar", - "ChangePasswordDescription": "På den här sidan kan du ändra ditt lösenord. Vid behov kan du också regenerera ditt nyckelpar—används för att nollställa ditt lösenord utan att be admin om hjälp.", + "ChangePasswordDescription": "På den här sidan kan du ändra ditt lösenord.", "Password": "lösenord", "PasswordMin": "Måste vara minst 9 karaktärer långt", "PasswordForgot": "Glömt lösenordet?", @@ -150,6 +194,7 @@ var Swedish = map[string]string{ "ThreadNew": "ny tråd", "ThreadThe": "tråden", "Index": "index", + "GoBackToTheThread": "Go back to the thread", "ThreadCreate": "Skapa en tråd", "Title": "Titel", @@ -158,7 +203,6 @@ var Swedish = map[string]string{ "TextareaPlaceholder": "Tabula rasa", "PasswordReset": "nollställ lösenord", - "PasswordResetMessage": "Du är inloggad, logga ut för att nollställga lösenordet med skapat lösenordsbevis", "PasswordResetSuccess": "Nollställning av lösenord—lyckades!", "PasswordResetSuccessMessage": "Du har nollställt ditt lösenord!", "PasswordResetSuccessLinkMessage": "Ge det ett försök och", @@ -177,12 +221,15 @@ var Swedish = map[string]string{ "NewThreadLinkMessage": "Om du är en medlem,", "NewThreadCreateError": "Fel uppstod vid trådskapning", "NewThreadCreateErrorMessage": "Det uppstod ett databasfel under trådskapningen, ursäkta.", + "PostEdit": "Post preview", "AriaPostMeta": "Post meta", "AriaDeletePost": "Delete this post", "AriaRespondIntoThread": "Respond into this thread", "PromptDeleteQuestion": "Radera post för alltid?", "Delete": "radera", + "Edit": "redigera", + "EditedAt": "redigerat", "Post": "post", "Author": "Författare", "Responded": "svarade", @@ -192,9 +239,6 @@ var Swedish = map[string]string{ "ThreadStartNew": "Starta ny tråd", "RegisterHTMLMessage": `Du har nu ett konto! Välkommen. Besök <a href="/">trådindexet</a> för att läsa och svara på trådar, eller för att starta en ny.`, - "RegisterKeypairExplanationStart": `En grej till: <b>spara nyckeln du ser nedan</b>. Det är ett <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">nyckelpar</a> som tillhandahåller din forumidentitet, och inkluderar en hemlig del som bara du vet om och endast visas nu; forumdatabasen kommer enbart ihåg den publika delen.`, - "RegisterViewKeypairExplanationEnd": `Med detta nyckelpar kan du återställa ditt lösenord om du skulle tappa bort det—och detta utan att behöva samla in din email (eller kräva emailinfrastruktur på forumets sida).`, - "RegisterKeypairWarning": "Detta nyckelpar visas enbart denna gång", "RegisterVerificationCode": "Din verifikationskod är", "RegisterVerificationInstructionsTitle": "Verification instructions", @@ -204,20 +248,6 @@ var Swedish = map[string]string{ "PasswordResetDescription": "På denna sida går vi igenom ett par steg för att säkert nollställa ditt lösenord—utan att behöva ta till mejl!", "PasswordResetUsernameQuestion": "För de första: hur löd användarnamnet?", - "PasswordResetCopyPayload": `Kopiera nu textsnutten nedan (aka <i>beviset</i>)`, - "PasswordResetFollowToolInstructions": `Följ <b>verktygsinstruktionerna</b> för att finalisera nollställningen.`, - "ToolInstructions": `verktygsinstruktionerna`, - "PasswordResetToolInstructions": fmt.Sprintf(` - <ul> - <li><a href="%s">Ladda ned verktyget</a></li> - <li>Kör det så hör:<br><code>pwtool --payload &lt;payload från ovan&gt; --keypair &lt;filvägen innehållandes ditt nyckelpar från när du registrerade dig&gt;</code> - </li> - <li>Kopiera det genererade beviset och klistra in nedan</li> - <li>(Kom ihåg att spara ditt lösenord:)</li> - </ul> - `, toolURL), - "GeneratePayload": "skapa payload", - "Proof": "bevis", "NewPassword": "nytt lösenord", "ChangePassword": "ändra lösenord", } @@ -246,8 +276,7 @@ var EspanolMexicano = map[string]string{ "Username": "usuarie", "Current": "current", "New": "new", - "GenerateNewKeypair": "I also want to generate a new keypair", - "ChangePasswordDescription": "Use this page to change your password. If needed, you can also regenerate your password reset keypair—used to reset a forgotten password without admin help.", + "ChangePasswordDescription": "Use this page to change your password.", "Password": "contraseña", "PasswordMin": "Debe tener por lo menos 9 caracteres.", "PasswordForgot": "Olvidaste tu contraseña?", @@ -256,6 +285,7 @@ var EspanolMexicano = map[string]string{ "ThreadNew": "new thread", "ThreadThe": "the thread", "Index": "index", + "GoBackToTheThread": "Go back to the thread", "ThreadCreate": "Create thread", "Title": "Title", @@ -264,7 +294,6 @@ var EspanolMexicano = map[string]string{ "TextareaPlaceholder": "Tabula rasa", "PasswordReset": "reset password", - "PasswordResetMessage": "You are logged in, log out to reset password using proof", "PasswordResetSuccess": "Reset password—success!", "PasswordResetSuccessMessage": "You reset your password!", "PasswordResetSuccessLinkMessage": "Give it a try and", @@ -283,6 +312,7 @@ var EspanolMexicano = map[string]string{ "NewThreadLinkMessage": "If you are a member,", "NewThreadCreateError": "Error creating thread", "NewThreadCreateErrorMessage": "There was a database error when creating the thread, apologies.", + "PostEdit": "Post preview", "ThreadStartNew": "Start a new thread", "AriaPostMeta": "Post meta", @@ -291,15 +321,15 @@ var EspanolMexicano = map[string]string{ "AriaHome": "Home", "PromptDeleteQuestion": "Delete post for all posterity?", "Delete": "delete", + "Edit": "editar", + "EditedAt": "editado a las", "Post": "post", + "Save": "Save", "Author": "Author", "Responded": "responded", "YourAnswer": "Your answer", "RegisterHTMLMessage": `You now have an account! Welcome. Visit the <a href="/">index</a> to read and reply to threads, or start a new one.`, - "RegisterKeypairExplanationStart": `There's just one more thing: <b>save the key displayed below</b>. It is a <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">keypair</a> describing your forum identity, with a private part that only you know; the forum only stores the public portion.`, - "RegisterViewKeypairExplanationEnd": `With this keypair you will be able to reset your account if you ever lose your password—and without having to share your email (or require email infrastructure on the forum's part).`, - "RegisterKeypairWarning": "This keypair will only be displayed once", "RegisterVerificationCode": "Your verification code is", "RegisterVerificationInstructionsTitle": "Verification instructions", @@ -309,20 +339,6 @@ var EspanolMexicano = map[string]string{ "PasswordResetDescription": "On this page we'll go through a few steps to securely reset your password—without resorting to any emails!", "PasswordResetUsernameQuestion": "First up: what was your username?", - "PasswordResetCopyPayload": `Now, first copy the snippet (aka <i>proof payload</i>) below`, - "PasswordResetFollowToolInstructions": `Follow the <b>tool instructions</b> to finalize the password reset.`, - "ToolInstructions": `tool instructions`, - "PasswordResetToolInstructions": fmt.Sprintf(` - <ul> - <li><a href="%s">Download the tool</a></li> - <li>Run as:<br><code>pwtool --payload &lt;proof payload from above&gt; --keypair &lt;path to file with yr keypair from registration&gt;</code> - </li> - <li>Copy the generated proof and paste below</li> - <li>(Remember to save your password :)</li> - </ul> - `, toolURL), - "GeneratePayload": "generate payload", - "Proof": "proof", "NewPassword": "new password", "ChangePassword": "change password", } diff --git a/server/moderation.go b/server/moderation.go @@ -0,0 +1,459 @@ +package server + +import ( + "fmt" + "html/template" + "strconv" + "net/http" + "time" + + "cerca/database" + "cerca/crypto" + "cerca/constants" + "cerca/i18n" + "cerca/util" +) + +type AdminData struct { + Admins []database.User + Users []database.User + Proposals []PendingProposal + IsAdmin bool +} + +type ModerationData struct { + Log []string +} + +type PendingProposal struct { + // ID is the id of the proposal + ID, ProposerID int + Action string + Time time.Time // the time self-confirmations become possible for proposers + TimePassed bool // self-confirmations valid or not +} + +func (h RequestHandler) displayErr(res http.ResponseWriter, req *http.Request, err error, title string) { + errMsg := util.Eout(err, fmt.Sprintf("%s failed", title)) + fmt.Println(errMsg) + data := GenericMessageData{ + Title: title, + Message: errMsg.Error(), + } + h.renderGenericMessage(res, req, data) +} + +func (h RequestHandler) displaySuccess(res http.ResponseWriter, req *http.Request, title, message, backRoute string) { + data := GenericMessageData{ + Title: title, + Message: message, + LinkText: h.translator.Translate("GoBack"), + Link: backRoute, + } + h.renderGenericMessage(res, req, data) +} + +// TODO (2023-12-10): any vulns with this approach? could a user forge a session cookie with the user id of an admin? +func (h RequestHandler) IsAdmin(req *http.Request) (bool, int) { + ed := util.Describe("IsAdmin") + userid, err := h.session.Get(req) + err = ed.Eout(err, "getting userid from session cookie") + if err != nil { + dump(err) + return false, -1 + } + + // make sure the user from the cookie actually exists + userExists, err := h.db.CheckUserExists(userid) + if err != nil { + dump(ed.Eout(err, "check userid in db")) + return false, -1 + } else if !userExists { + return false, -1 + } + // make sure the user id is actually an admin + userIsAdmin, err := h.db.IsUserAdmin(userid) + if err != nil { + dump(ed.Eout(err, "IsUserAdmin in db")) + return false, -1 + } else if !userIsAdmin { + return false, -1 + } + return true, userid +} + +// there is a 2-quorum (requires 2 admins to take effect) imposed for the following actions, which are regarded as +// consequential: +// * make admin +// * remove account +// * demote admin + +// note: there is only a 2-quorum constraint imposed if there are actually 2 admins. an admin may also confirm their own +// proposal if constants.PROPOSAL_SELF_CONFIRMATION_WAIT seconds have passed (1 week) +func performQuorumCheck (ed util.ErrorDescriber, db *database.DB, adminUserId, targetUserId, proposedAction int) error { + // checks if a quorum is necessary for the proposed action: if a quorum constarin is in effect, a proposal is created + // otherwise (if no quorum threshold has been achieved) the action is taken directly + quorumActivated := db.QuorumActivated() + + var err error + var modlogErr error + if quorumActivated { + err = db.ProposeModerationAction(adminUserId, targetUserId, proposedAction) + } else { + switch proposedAction { + case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: + err = db.RemoveUser(targetUserId) + modlogErr = db.AddModerationLog(adminUserId, -1, constants.MODLOG_REMOVE_USER) + case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: + err = db.AddAdmin(targetUserId) + modlogErr = db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_MAKE) + case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: + err = db.DemoteAdmin(targetUserId) + modlogErr = db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_DEMOTE) + } + } + if modlogErr != nil { + fmt.Println(ed.Eout(err, "error adding moderation log")) + } + if err != nil { + return err + } + return nil +} + +func (h *RequestHandler) AdminRemoveUser(res http.ResponseWriter, req *http.Request, targetUserId int) { + ed := util.Describe("Admin remove user") + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, adminUserId := h.IsAdmin(req) + + if req.Method == "GET" || !loggedIn || !isAdmin { + IndexRedirect(res, req) + return + } + + err := performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER) + + if err != nil { + h.displayErr(res, req, err, "User removal") + return + } + + // success! redirect back to /admin + http.Redirect(res, req, "/admin", http.StatusFound) +} + +func (h *RequestHandler) AdminMakeUserAdmin(res http.ResponseWriter, req *http.Request, targetUserId int) { + ed := util.Describe("make user admin") + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, adminUserId := h.IsAdmin(req) + if req.Method == "GET" || !loggedIn || !isAdmin { + IndexRedirect(res, req) + return + } + + title := h.translator.Translate("AdminMakeAdmin") + + err := performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN) + + if err != nil { + h.displayErr(res, req, err, title) + return + } + + if !h.db.QuorumActivated() { + username, _ := h.db.GetUsername(targetUserId) + message := fmt.Sprintf("User %s is now a fellow admin user!", username) + h.displaySuccess(res, req, title, message, "/admin") + } else { + // redirect to admin view, which should have a proposal now + http.Redirect(res, req, "/admin", http.StatusFound) + } +} + +func (h *RequestHandler) AdminDemoteAdmin(res http.ResponseWriter, req *http.Request) { + ed := util.Describe("demote admin route") + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, adminUserId := h.IsAdmin(req) + + if req.Method == "GET" || !loggedIn || !isAdmin { + IndexRedirect(res, req) + return + } + + title := h.translator.Translate("AdminDemote") + + useridString := req.PostFormValue("userid") + targetUserId, err := strconv.Atoi(useridString) + util.Check(err, "convert user id string to a plain userid") + + err = performQuorumCheck(ed, h.db, adminUserId, targetUserId, constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN) + + if err != nil { + h.displayErr(res, req, err, title) + return + } + + if !h.db.QuorumActivated() { + username, _ := h.db.GetUsername(targetUserId) + message := fmt.Sprintf("User %s is now a regular user", username) + // output copy-pastable credentials page for admin to send to the user + h.displaySuccess(res, req, title, message, "/admin") + } else { + http.Redirect(res, req, "/admin", http.StatusFound) + } +} + +func (h *RequestHandler) AdminManualAddUserRoute(res http.ResponseWriter, req *http.Request) { + ed := util.Describe("admin manually add user") + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, adminUserId := h.IsAdmin(req) + + if !isAdmin { + IndexRedirect(res, req) + return + } + + type AddUser struct { + ErrorMessage string + } + + var data AddUser + view := TemplateData{Title: h.translator.Translate("AdminAddNewUser"), Data: &data, HasRSS: false, IsAdmin: isAdmin, LoggedIn: loggedIn} + + if req.Method == "GET" { + h.renderView(res, "admin-add-user", view) + return + } + + if req.Method == "POST" && isAdmin { + username := req.PostFormValue("username") + + // do a lil quick checky check to see if we already have that username registered, + // and if we do re-render the page with an error + existed, err := h.db.CheckUsernameExists(username) + ed.Check(err, "check username exists") + + if existed { + data.ErrorMessage = fmt.Sprintf("Username (%s) is already registered", username) + h.renderView(res, "admin-add-user", view) + return + } + + // set up basic credentials + newPassword := crypto.GeneratePassword() + passwordHash, err := crypto.HashPassword(newPassword) + ed.Check(err, "hash password") + targetUserId, err := h.db.CreateUser(username, passwordHash) + ed.Check(err, "create new user %s", username) + + err = h.db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_ADMIN_ADD_USER) + if err != nil { + fmt.Println(ed.Eout(err, "error adding moderation log")) + } + + title := h.translator.Translate("AdminAddNewUser") + message := fmt.Sprintf(h.translator.Translate("AdminPasswordSuccessInstructions"), template.HTMLEscapeString(username), newPassword) + h.displaySuccess(res, req, title, message, "/add-user") + } +} + +func (h *RequestHandler) AdminResetUserPassword(res http.ResponseWriter, req *http.Request, targetUserId int) { + ed := util.Describe("admin reset password") + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, adminUserId := h.IsAdmin(req) + if req.Method == "GET" || !loggedIn || !isAdmin { + IndexRedirect(res, req) + return + } + + title := util.Capitalize(h.translator.Translate("PasswordReset")) + newPassword, err := h.db.ResetPassword(targetUserId) + + if err != nil { + h.displayErr(res, req, err, title) + return + } + + err = h.db.AddModerationLog(adminUserId, targetUserId, constants.MODLOG_RESETPW) + if err != nil { + fmt.Println(ed.Eout(err, "error adding moderation log")) + } + + username, _ := h.db.GetUsername(targetUserId) + + message := fmt.Sprintf(h.translator.Translate("AdminPasswordSuccessInstructions"), template.HTMLEscapeString(username), newPassword) + h.displaySuccess(res, req, title, message, "/admin") +} + +func (h *RequestHandler) ConfirmProposal(res http.ResponseWriter, req *http.Request) { + h.HandleProposal(res, req, constants.PROPOSAL_CONFIRM) +} + +func (h *RequestHandler) VetoProposal(res http.ResponseWriter, req *http.Request) { + h.HandleProposal(res, req, constants.PROPOSAL_VETO) +} + +func (h *RequestHandler) HandleProposal(res http.ResponseWriter, req *http.Request, decision bool) { + ed := util.Describe("handle proposal proposal") + isAdmin, adminUserId := h.IsAdmin(req) + + if !isAdmin { + IndexRedirect(res, req) + return + } + + if req.Method == "POST" { + proposalidString := req.PostFormValue("proposalid") + proposalid, err := strconv.Atoi(proposalidString) + ed.Check(err, "convert proposalid") + err = h.db.FinalizeProposedAction(proposalid, adminUserId, decision) + if err != nil { + ed.Eout(err, "finalizing the proposed action returned early with an error") + } + http.Redirect(res, req, "/admin", http.StatusFound) + return + } + IndexRedirect(res, req) +} + +// Note: this route by definition contains user generated content, so we escape all usernames with +// html.EscapeString(username) +func (h *RequestHandler) ModerationLogRoute(res http.ResponseWriter, req *http.Request) { + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, _ := h.IsAdmin(req) + // logs are sorted by time descending, from latest entry to oldest + logs := h.db.GetModerationLogs() + viewData := ModerationData{Log: make([]string, 0)} + + type translationData struct { + Time, ActingUsername, RecipientUsername string + Action template.HTML + } + + for _, entry := range logs { + var tdata translationData + var translationString string + tdata.Time = entry.Time.Format("2006-01-02 15:04:05") + tdata.ActingUsername = template.HTMLEscapeString(entry.ActingUsername) + tdata.RecipientUsername = template.HTMLEscapeString(entry.RecipientUsername) + switch entry.Action { + case constants.MODLOG_RESETPW: + translationString = "modlogResetPassword" + if isAdmin { + translationString += "Admin" + } + case constants.MODLOG_ADMIN_MAKE: + translationString = "modlogMakeAdmin" + case constants.MODLOG_REMOVE_USER: + translationString = "modlogRemoveUser" + case constants.MODLOG_ADMIN_ADD_USER: + translationString = "modlogAddUser" + if isAdmin { + translationString += "Admin" + } + case constants.MODLOG_ADMIN_DEMOTE: + translationString = "modlogDemoteAdmin" + case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: + translationString = "modlogProposalDemoteAdmin" + case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: + translationString = "modlogProposalMakeAdmin" + case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: + translationString = "modlogProposalRemoveUser" + } + + actionString := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: tdata}) + + /* rendering of decision (confirm/veto) taken on a pending proposal */ + if entry.QuorumUsername != "" { + // use the translated actionString to embed in the translated proposal decision (confirmation/veto) + propdata := translationData{ActingUsername: template.HTMLEscapeString(entry.QuorumUsername), Action: template.HTML(actionString)} + // if quorumDecision is true -> proposal was confirmed + translationString = "modlogConfirm" + if !entry.QuorumDecision { + translationString = "modlogVeto" + } + proposalString := h.translator.TranslateWithData(translationString, i18n.TranslationData{Data: propdata}) + viewData.Log = append(viewData.Log, proposalString) + /* rendering of "X proposed: <Y>" */ + } else if entry.Action == constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN || + entry.Action == constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN || + entry.Action == constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER { + propXforY := translationData{Time: tdata.Time, ActingUsername: tdata.ActingUsername, Action: template.HTML(actionString)} + proposalString := h.translator.TranslateWithData("modlogXProposedY", i18n.TranslationData{Data: propXforY}) + viewData.Log = append(viewData.Log, proposalString) + } else { + viewData.Log = append(viewData.Log, actionString) + } + } + view := TemplateData{Title: h.translator.Translate("ModerationLog"), IsAdmin: isAdmin, LoggedIn: loggedIn, Data: viewData} + h.renderView(res, "moderation-log", view) +} + +// used for rendering /admin's pending proposals +func (h *RequestHandler) AdminRoute(res http.ResponseWriter, req *http.Request) { + loggedIn, userid := h.IsLoggedIn(req) + isAdmin, _ := h.IsAdmin(req) + + if req.Method == "POST" && loggedIn && isAdmin { + action := req.PostFormValue("admin-action") + useridString := req.PostFormValue("userid") + targetUserId, err := strconv.Atoi(useridString) + util.Check(err, "convert user id string to a plain userid") + + switch action { + case "reset-password": + h.AdminResetUserPassword(res, req, targetUserId) + case "make-admin": + h.AdminMakeUserAdmin(res, req, targetUserId) + case "remove-account": + h.AdminRemoveUser(res, req, targetUserId) + } + return + } + + if req.Method == "GET" { + if !loggedIn || !isAdmin { + // non-admin users get a different view + h.ListAdmins(res, req) + return + } + admins := h.db.GetAdmins() + normalUsers := h.db.GetUsers(false) // do not include admins + proposedActions := h.db.GetProposedActions() + // massage pending proposals into something we can use in the rendered view + pendingProposals := make([]PendingProposal, len(proposedActions)) + now := time.Now() + for i, prop := range proposedActions { + // escape all ugc + prop.ActingUsername = template.HTMLEscapeString(prop.ActingUsername) + prop.RecipientUsername = template.HTMLEscapeString(prop.RecipientUsername) + // one week from when the proposal was made + t := prop.Time.Add(constants.PROPOSAL_SELF_CONFIRMATION_WAIT) + var str string + switch prop.Action { + case constants.MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN: + str = "modlogProposalDemoteAdmin" + case constants.MODLOG_ADMIN_PROPOSE_MAKE_ADMIN: + str = "modlogProposalMakeAdmin" + case constants.MODLOG_ADMIN_PROPOSE_REMOVE_USER: + str = "modlogProposalRemoveUser" + } + + proposalString := h.translator.TranslateWithData(str, i18n.TranslationData{Data: prop}) + pendingProposals[i] = PendingProposal{ID: prop.ProposalID, ProposerID: prop.ActingID, Action: proposalString, Time: t, TimePassed: now.After(t)} + } + data := AdminData{Admins: admins, Users: normalUsers, Proposals: pendingProposals} + view := TemplateData{Title: h.translator.Translate("AdminForumAdministration"), Data: &data, HasRSS: false, LoggedIn: loggedIn, LoggedInID: userid} + h.renderView(res, "admin", view) + } +} + +// view of /admin for non-admin users (contains less information) +func (h *RequestHandler) ListAdmins(res http.ResponseWriter, req *http.Request) { + loggedIn, _ := h.IsLoggedIn(req) + admins := h.db.GetAdmins() + data := AdminData{Admins: admins} + view := TemplateData{Title: h.translator.Translate("AdminForumAdministration"), Data: &data, HasRSS: false, LoggedIn: loggedIn} + h.renderView(res, "admins-list", view) + return +} diff --git a/server/server.go b/server/server.go @@ -2,7 +2,7 @@ package server import ( "context" - "encoding/hex" + "database/sql" "errors" "fmt" "html/template" @@ -12,7 +12,6 @@ import ( "net/url" "os" "path/filepath" - "regexp" "strings" "syscall" "time" @@ -36,7 +35,8 @@ import ( type TemplateData struct { Data interface{} QuickNav bool - LoggedIn bool // TODO (2022-01-09): put this in a middleware || template function or sth? + LoggedIn bool + IsAdmin bool HasRSS bool LoggedInID int ForumName string @@ -51,7 +51,6 @@ type PasswordResetData struct { type ChangePasswordData struct { Action string - Keypair string } type IndexData struct { @@ -74,10 +73,6 @@ type RegisterData struct { ConductLink string } -type RegisterSuccessData struct { - Keypair string -} - type LoginData struct { FailedAttempt bool } @@ -88,6 +83,11 @@ type ThreadData struct { ThreadURL string } +type EditPostData struct { + Title string + Content string +} + type RequestHandler struct { db *database.DB session *session.Session @@ -185,6 +185,7 @@ func generateTemplates(config types.Config, translator i18n.Translator) (*templa "formatDate": func(t time.Time) string { return t.Format("2006-01-02") }, + "formatDateTimeRelative": util.RelativeTime, "formatDateRelative": func(t time.Time) string { diff := time.Since(t) if diff < time.Hour*24 { @@ -208,6 +209,7 @@ func generateTemplates(config types.Config, translator i18n.Translator) (*templa return translator.TranslateWithData(key, i18n.TranslationData{data}) }, "capitalize": util.Capitalize, + "markup": util.Markup, "tohtml": func(s string) template.HTML { // use of this function is risky cause it interprets the passed in string and renders it as unescaped html. // can allow for attacks! @@ -223,6 +225,7 @@ func generateTemplates(config types.Config, translator i18n.Translator) (*templa "footer", "generic-message", "head", + "edit-post", "index", "login", "login-component", @@ -230,6 +233,10 @@ func generateTemplates(config types.Config, translator i18n.Translator) (*templa "register", "register-success", "thread", + "admin", + "admins-list", + "admin-add-user", + "moderation-log", "password-reset", "change-password", "change-password-success", @@ -271,9 +278,25 @@ func (h RequestHandler) renderView(res http.ResponseWriter, viewName string, dat } } +func (h RequestHandler) renderGenericMessage(res http.ResponseWriter, req *http.Request, incomingData GenericMessageData) { + loggedIn, _ := h.IsLoggedIn(req) + isAdmin, _ := h.IsAdmin(req) + data := TemplateData{ + Data: incomingData, + // the following two fields are defaults that usually are not set and which are cumbersome to set each time since + // they don't really matter / vary across invocations + HasRSS: h.config.RSS.URL != "", + LoggedIn: loggedIn, + IsAdmin: isAdmin, + } + h.renderView(res, "generic-message", data) + return +} + func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) { threadid, ok := util.GetURLPortion(req, 2) loggedIn, userid := h.IsLoggedIn(req) + isAdmin, _ := h.IsAdmin(req) if !ok { title := h.translator.Translate("ErrThread404") @@ -281,7 +304,7 @@ func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) Title: title, Message: h.translator.Translate("ErrThread404Message"), } - h.renderView(res, "generic-message", TemplateData{Data: data, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn}) + h.renderGenericMessage(res, req, data) return } @@ -306,18 +329,8 @@ func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) // TODO (2022-01-07): // * handle error thread := h.db.GetThread(threadid) - pattern := regexp.MustCompile("<img") - // markdownize content (but not title) - for i, post := range thread { - content := []byte(util.Markup(post.Content)) - // make sure images are lazy loaded - if pattern.Match(content) { - content = pattern.ReplaceAll(content, []byte(`<img loading="lazy"`)) - } - thread[i].Content = template.HTML(content) - } data := ThreadData{Posts: thread, ThreadURL: req.URL.Path} - view := TemplateData{Data: &data, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid} + view := TemplateData{Data: &data, IsAdmin: isAdmin, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid} if len(thread) > 0 { data.Title = thread[0].ThreadTitle view.Title = data.Title @@ -331,7 +344,7 @@ func (h RequestHandler) ErrorRoute(res http.ResponseWriter, req *http.Request, s Title: title, Message: fmt.Sprintf(h.translator.Translate("ErrGeneric404Message"), status), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + h.renderGenericMessage(res, req, data) } func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { @@ -342,6 +355,7 @@ func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { } loggedIn, _ := h.IsLoggedIn(req) var mostRecentPost bool + isAdmin, _ := h.IsAdmin(req) params := req.URL.Query() if q, exists := params["sort"]; exists { @@ -350,7 +364,7 @@ func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { } // show index listing threads := h.db.ListThreads(mostRecentPost) - view := TemplateData{Data: IndexData{threads}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Threads")} + view := TemplateData{Data: IndexData{threads}, IsAdmin: isAdmin, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Threads")} h.renderView(res, "index", view) } @@ -464,7 +478,7 @@ func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http. Link: "/reset", LinkText: h.translator.Translate("GoBack"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + h.renderGenericMessage(res, req, data) } _, uid := h.IsLoggedIn(req) @@ -480,23 +494,6 @@ func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http. case "/reset/submit": oldPassword := req.PostFormValue("password-old") newPassword := req.PostFormValue("password-new") - resetKeypair := (req.PostFormValue("reset-keypair") == "true") - var keypairString string - - // check if we're resetting keypair - if resetKeypair { - // if so: generate new keypair - kp, err := crypto.GenerateKeypair() - ed.Check(err, "generate keypair") - kpBytes, err := kp.Marshal() - ed.Check(err, "marshal keypair") - pubkey, err := kp.PublicString() - ed.Check(err, "get pubkey string") - // and set it in db - err = h.db.SetPubkey(uid, pubkey) - ed.Check(err, "set new pubkey in database") - keypairString = string(kpBytes) - } // check that the submitted, old password is valid username, err := h.db.GetUsername(uid) @@ -526,7 +523,7 @@ func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http. // then save the hash h.db.UpdateUserPasswordHash(uid, pwhashNew) // render a success message & show a link to the login page :') - h.renderView(res, "change-password-success", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: true, Data: ChangePasswordData{Keypair: keypairString}}) + h.renderView(res, "change-password-success", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: true, Data: ChangePasswordData{}}) default: fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) IndexRedirect(res, req) @@ -538,130 +535,28 @@ func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http. } func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Request) { - ed := util.Describe("password proof route") loggedIn, _ := h.IsLoggedIn(req) title := util.Capitalize(h.translator.Translate("PasswordReset")) - // change password functionality, handle this in another function + // the user was logged in, let them change their password themselves :) if loggedIn { h.handleChangePassword(res, req) return } - renderErr := func(errFmt string, args ...interface{}) { + renderPlaceholder := func(errFmt string, args ...interface{}) { errMessage := fmt.Sprintf(errFmt, args...) fmt.Println(errMessage) data := GenericMessageData{ Title: title, Message: errMessage, - Link: "/reset", + Link: "/", LinkText: h.translator.Translate("GoBack"), } h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) } - - switch req.Method { - case "GET": - switch req.URL.Path { - case "/reset/submit": - params := req.URL.Query() - getParam := func(key string) string { - if q, exists := params[key]; exists { - return q[0] - } - fmt.Println("can't find param", key) - return "" - } - username := getParam("username") - payload := getParam("payload") - h.renderView(res, "password-reset", TemplateData{Data: PasswordResetData{Action: "/reset/submit", Username: username, Payload: payload}}) - default: - h.renderView(res, "password-reset", TemplateData{Data: PasswordResetData{Action: "/reset/generate"}}) - } - case "POST": - username := req.PostFormValue("username") - switch req.URL.Path { - case "/reset/generate": - constructProofPayload := func() string { - return fmt.Sprintf("%s::%s", username, crypto.GenerateNonce()) - } - payload := constructProofPayload() - params := fmt.Sprintf("?payload=%s&username=%s", payload, username) - http.Redirect(res, req, "/reset/submit"+params, http.StatusSeeOther) - case "/reset/submit": - password := req.PostFormValue("password") - proofString := req.PostFormValue("proof") - payload := req.PostFormValue("payload") - - // make sure the user exists - userid, err := h.db.GetUserID(username) - if err != nil { - renderErr("Wrong username, or a non-existent user") - return - } - - // make sure the nonce / payload is not being reused - nonceExisted, err := h.db.CheckNonceExists(payload) - if err != nil { - dump(ed.Eout(err, "check nonce existed")) - return - } - if nonceExisted { - renderErr("This payload has already been used, please generate a new one") - return - } - - // get the pubkey, as it is saved in the database for the corresponding user - pubkeyString, err := h.db.GetPubkey(userid) - if err != nil { - renderErr("No matching pubkey found") - return - } - // convert to ed25519.PublicKey - pubkey := crypto.PublicKeyFromString(pubkeyString) - - proof, err := hex.DecodeString(proofString) - if err != nil { - renderErr("The proof format was incorrect") - return - } - - correct := crypto.VerifyProof(pubkey, []byte(payload), proof) - if !correct { - renderErr("The proof was incorrect") - return - } - // proof was correct! - // save the nonce, so it's not reused - err = h.db.AddNonce(payload) - if err != nil { - dump(ed.Eout(err, "insert nonce into database")) - return - } - // let's set the new password in the database. first, hash it - pwhash, err := crypto.HashPassword(password) - if err != nil { - dump(ed.Eout(err, "hash password during reset")) - return - } - h.db.UpdateUserPasswordHash(userid, pwhash) - // render a success message & show a link to the login page :') - data := GenericMessageData{ - Title: h.translator.Translate("PasswordResetSuccess"), - Message: h.translator.Translate("PasswordResetSuccessMessage"), - Link: "/login", - LinkMessage: h.translator.Translate("PasswordResetSuccessLinkMessage"), - LinkText: h.translator.Translate("Login"), - } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) - default: - fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) - IndexRedirect(res, req) - } - default: - fmt.Println("non get/post method, redirecting to index") - IndexRedirect(res, req) - } + renderPlaceholder("Password reset under construction: please contact admin if you need help resetting yr pw :)") + return } func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request) { @@ -676,12 +571,12 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request LinkMessage: h.translator.Translate("RegisterLinkMessage"), LinkText: h.translator.Translate("Index"), } - h.renderView(res, "generic-message", TemplateData{Data: data, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Register")}) + h.renderGenericMessage(res, req, data) return } - rules := util.Markup(template.HTML(h.files["rules"])) - verification := util.Markup(template.HTML(h.files["verification-instructions"])) + rules := util.Markup(string(h.files["rules"])) + verification := util.Markup(string(h.files["verification-instructions"])) conduct := h.config.Community.ConductLink var verificationCode string renderErr := func(errFmt string, args ...interface{}) { @@ -714,28 +609,32 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request } username := req.PostFormValue("username") password := req.PostFormValue("password") - // read verification code from form - verificationLink := req.PostFormValue("verificationlink") - // fmt.Printf("user: %s, verilink: %s\n", username, verificationLink) - u, err := url.Parse(verificationLink) - if err != nil { - renderErr("Had troubles parsing the verification link, are you sure it was a proper url?") - return - } - // check verification link domain against allowlist - if !util.Contains(h.allowlist, u.Host) { - fmt.Println(h.allowlist, u.Host, util.Contains(h.allowlist, u.Host)) - renderErr("Verification link's host (%s) is not in the allowlist", u.Host) - return - } - - // parse out verification code from verification link and compare against verification code in session - has := hasVerificationCode(verificationLink, verificationCode) - if !has { - if !developing { - renderErr("Verification code from link (%s) does not match", verificationLink) + var verificationLink string + // skip verification code during dev registering + if !developing { + // read verification code from form + verificationLink = req.PostFormValue("verificationlink") + // fmt.Printf("user: %s, verilink: %s\n", username, verificationLink) + u, err := url.Parse(verificationLink) + if err != nil { + renderErr("Had troubles parsing the verification link, are you sure it was a proper url?") return } + // check verification link domain against allowlist + if !util.Contains(h.allowlist, u.Host) { + fmt.Println(h.allowlist, u.Host, util.Contains(h.allowlist, u.Host)) + renderErr("Verification link's host (%s) is not in the allowlist", u.Host) + return + } + + // parse out verification code from verification link and compare against verification code in session + has := hasVerificationCode(verificationLink, verificationCode) + if !has { + if !developing { + renderErr("Verification code from link (%s) does not match", verificationLink) + return + } + } } // make sure username is not registered already var exists bool @@ -764,22 +663,7 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request if err = ed.Eout(err, "add registration"); err != nil { dump(err) } - // generate and pass public keypair - keypair, err := crypto.GenerateKeypair() - ed.Check(err, "generate keypair") - // record generated pubkey in database for eventual later use - pub, err := keypair.PublicString() - if err = ed.Eout(err, "convert pubkey to string"); err != nil { - dump(err) - } - ed.Check(err, "stringify pubkey") - err = h.db.AddPubkey(userID, pub) - if err = ed.Eout(err, "insert pubkey in db"); err != nil { - dump(err) - } - kpJson, err := keypair.Marshal() - ed.Check(err, "marshal keypair") - h.renderView(res, "register-success", TemplateData{Data: RegisterSuccessData{string(kpJson)}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("RegisterSuccess")}) + h.renderView(res, "register-success", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: true, Title: h.translator.Translate("RegisterSuccess")}) default: fmt.Println("non get/post method, redirecting to index") IndexRedirect(res, req) @@ -795,12 +679,12 @@ func (h RequestHandler) GenericRoute(res http.ResponseWriter, req *http.Request) LinkMessage: "Generic link messsage", LinkText: "with link", } - h.renderView(res, "generic-message", TemplateData{Data: data}) + h.renderGenericMessage(res, req, data) } func (h RequestHandler) AboutRoute(res http.ResponseWriter, req *http.Request) { loggedIn, _ := h.IsLoggedIn(req) - input := util.Markup(template.HTML(h.files["about"])) + input := util.Markup(string(h.files["about"])) h.renderView(res, "about-template", TemplateData{Data: input, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("About")}) } @@ -823,7 +707,7 @@ func (h *RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reque LinkMessage: h.translator.Translate("NewThreadLinkMessage"), LinkText: h.translator.Translate("LogIn"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + h.renderGenericMessage(res, req, data) return } h.renderView(res, "new-thread", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("ThreadNew")}) @@ -839,7 +723,7 @@ func (h *RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reque Title: h.translator.Translate("NewThreadCreateError"), Message: h.translator.Translate("NewThreadCreateErrorMessage"), } - h.renderView(res, "generic-message", TemplateData{Data: data, Title: h.translator.Translate("ThreadNew")}) + h.renderGenericMessage(res, req, data) return } // update the rss feed @@ -873,7 +757,7 @@ func (h *RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Requ renderErr := func(msg string) { fmt.Println(msg) genericErr.Message = msg - h.renderView(res, "generic-message", TemplateData{Data: genericErr, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn}) + h.renderGenericMessage(res, req, genericErr) } if !loggedIn || !ok { @@ -908,6 +792,39 @@ func (h *RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Requ http.Redirect(res, req, threadURL, http.StatusSeeOther) } +func (h *RequestHandler) EditPostRoute(res http.ResponseWriter, req *http.Request) { + postid, ok := util.GetURLPortion(req, 3) + loggedIn, userid := h.IsLoggedIn(req) + post, err := h.db.GetPost(postid) + + if !ok || errors.Is(err, sql.ErrNoRows) { + title := h.translator.Translate("ErrEdit404") + data := GenericMessageData{ + Title: title, + Message: h.translator.Translate("ErrEdit404Message"), + } + h.renderGenericMessage(res, req, data) + return + } + if !loggedIn || userid != post.AuthorID { + res.WriteHeader(401) + title := h.translator.Translate("ErrGeneric401") + data := GenericMessageData{ + Title: title, + Message: h.translator.Translate("ErrGeneric401Message"), + } + h.renderGenericMessage(res, req, data) + return + } + if req.Method == "POST" { + content := req.PostFormValue("content") + h.db.EditPost(content, postid) + post.Content = content + } + view := TemplateData{Data: post, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid} + h.renderView(res, "edit-post", view) +} + func Serve(allowlist []string, sessionKey string, isdev bool, dir string, conf types.Config) { port := ":8272" @@ -994,12 +911,22 @@ func NewServer(allowlist []string, sessionKey, dir string, config types.Config) /* note: be careful with trailing slashes; go's default handler is a bit sensitive */ // TODO (2022-01-10): introduce middleware to make sure there is never an issue with trailing slashes + + // moderation and admin related routes, for contents see file server/moderation.go s.ServeMux.HandleFunc("/reset/", handler.ResetPasswordRoute) + s.ServeMux.HandleFunc("/admin", handler.AdminRoute) + s.ServeMux.HandleFunc("/demote-admin", handler.AdminDemoteAdmin) + s.ServeMux.HandleFunc("/add-user", handler.AdminManualAddUserRoute) + s.ServeMux.HandleFunc("/moderations", handler.ModerationLogRoute) + s.ServeMux.HandleFunc("/proposal-veto", handler.VetoProposal) + s.ServeMux.HandleFunc("/proposal-confirm", handler.ConfirmProposal) + // regular ol forum routes s.ServeMux.HandleFunc("/about", handler.AboutRoute) s.ServeMux.HandleFunc("/logout", handler.LogoutRoute) s.ServeMux.HandleFunc("/login", handler.LoginRoute) s.ServeMux.HandleFunc("/register", handler.RegisterRoute) s.ServeMux.HandleFunc("/post/delete/", handler.DeletePostRoute) + s.ServeMux.HandleFunc("/post/edit/", handler.EditPostRoute) s.ServeMux.HandleFunc("/thread/new/", handler.NewThreadRoute) s.ServeMux.HandleFunc("/thread/", handler.ThreadRoute) s.ServeMux.HandleFunc("/robots.txt", handler.RobotsRoute) diff --git a/util/util.go b/util/util.go @@ -17,8 +17,10 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/parser" "github.com/komkom/toml" "github.com/microcosm-cc/bluemonday" "golang.org/x/exp/utf8string" @@ -92,13 +94,19 @@ var contentGuardian = bluemonday.UGCPolicy() var strictContentGuardian = bluemonday.StrictPolicy() // Turns Markdown input into HTML -func Markup(md template.HTML) template.HTML { +func Markup(md string) template.HTML { mdBytes := []byte(string(md)) // fix newlines mdBytes = markdown.NormalizeNewlines(mdBytes) - maybeUnsafeHTML := markdown.ToHTML(mdBytes, nil, nil) + mdParser := parser.NewWithExtensions(parser.CommonExtensions ^ parser.MathJax) + maybeUnsafeHTML := markdown.ToHTML(mdBytes, mdParser, nil) // guard against malicious code being embedded html := contentGuardian.SanitizeBytes(maybeUnsafeHTML) + // lazy load images + pattern := regexp.MustCompile("<img") + if pattern.Match(html) { + html = pattern.ReplaceAll(html, []byte(`<img loading="lazy"`)) + } return template.HTML(html) } @@ -182,6 +190,35 @@ func CreateIfNotExist(docpath, content string) (bool, error) { return false, nil } +const solarYearSecs = 31556926 + +func RelativeTime(t time.Time) string { + d := time.Since(t) + var metric string + var amount int + if d.Seconds() < 60 { + amount = int(d.Seconds()) + metric = "second" + } else if d.Minutes() < 60 { + amount = int(d.Minutes()) + metric = "minute" + } else if d.Hours() < 24 { + amount = int(d.Hours()) + metric = "hour" + } else if d.Seconds() < solarYearSecs { + amount = int(d.Hours()) / 24 + metric = "day" + } else { + amount = int(d.Seconds()) / solarYearSecs + metric = "year" + } + if amount == 1 { + return fmt.Sprintf("%d %s ago", amount, metric) + } else { + return fmt.Sprintf("%d %ss ago", amount, metric) + } +} + func ReadConfig(confpath string) types.Config { ed := Describe("config") _, err := CreateIfNotExist(confpath, defaults.DEFAULT_CONFIG)