cerca

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

commit cd7a6bbb612bf7a9e831c8651b80a688bbdfc1d3
Author: cblgh <cblgh@cblgh.org>
Date:   Tue, 11 Jan 2022 16:27:40 +0100

forum, initial release

Diffstat:
A.gitignore | 2++
Acrypto/crypto.go | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adatabase/database.go | 361+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 15+++++++++++++++
Ago.sum | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Ahtml/about.html | 39+++++++++++++++++++++++++++++++++++++++
Ahtml/assets/favicon.png | 0
Ahtml/assets/merveilles.svg | 7+++++++
Ahtml/footer.html | 4++++
Ahtml/generic-message.html | 6++++++
Ahtml/head.html | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahtml/index.html | 8++++++++
Ahtml/login-component.html | 9+++++++++
Ahtml/login.html | 12++++++++++++
Ahtml/new-thread.html | 10++++++++++
Ahtml/register-success.html | 16++++++++++++++++
Ahtml/register.html | 44++++++++++++++++++++++++++++++++++++++++++++
Ahtml/thread.html | 18++++++++++++++++++
Arun.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Aserver/server.go | 437+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/session/session.go | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autil/util.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
22 files changed, 1487 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +.*.sw[a-z] +data/ diff --git a/crypto/crypto.go b/crypto/crypto.go @@ -0,0 +1,81 @@ +package crypto + +import ( + "crypto/ed25519" + crand "crypto/rand" + "encoding/binary" + "encoding/json" + "fmt" + "cerca/util" + "github.com/synacor/argon2id" + rand "math/rand" +) + +type Keypair struct { + Public string `json:"public"` + Private string `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: fmt.Sprintf("%x", pub), Private: fmt.Sprintf("%x", priv)}, 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 { + err := json.Unmarshal(input, &kp) + if err != nil { + return util.Eout(err, "unmarshal keypair") + } + return nil +} + +func HashPassword(s string) (string, error) { + ed := util.Describe("hash password") + hash, err := argon2id.DefaultHashPassword(s) + if err != nil { + return "", ed.Eout(err, "hashing with argon2id") + } + return hash, nil +} + +func ValidatePasswordHash(password, passwordHash string) bool { + err := argon2id.Compare(passwordHash, password) + if err != nil { + return false + } + return true +} + +func GenerateVerificationCode() int { + var src cryptoSource + rnd := rand.New(src) + return rnd.Intn(999999) +} + +type cryptoSource struct{} + +func (s cryptoSource) Seed(seed int64) {} + +func (s cryptoSource) Int63() int64 { + return int64(s.Uint64() & ^uint64(1<<63)) +} + +func (s cryptoSource) Uint64() (v uint64) { + err := binary.Read(crand.Reader, binary.BigEndian, &v) + if err != nil { + util.Check(err, "generate random verification code") + } + return v +} diff --git a/database/database.go b/database/database.go @@ -0,0 +1,361 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "cerca/util" + "html/template" + "log" + "net/url" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type DB struct { + db *sql.DB +} + +func InitDB(filepath string) DB { + db, err := sql.Open("sqlite3", filepath) + util.Check(err, "opening sqlite3 database at %s", filepath) + if db == nil { + log.Fatalln("db is nil") + } + createTables(db) + return DB{db} +} + +func createTables(db *sql.DB) { + // create the table if it doesn't exist + queries := []string{ + /* used for versioning migrations */ + ` + CREATE TABLE IF NOT EXISTS meta ( + schemaversion INTEGER NOT NULL + ); + `, + ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + passwordhash TEXT NOT NULL + ); + `, + ` + CREATE TABLE IF NOT EXISTS pubkeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pubkey TEXT NOT NULL UNIQUE, + userid integer NOT NULL UNIQUE, + FOREIGN KEY (userid) REFERENCES users(id) + ); + `, + ` + CREATE TABLE IF NOT EXISTS registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + userid INTEGER, + host STRING, + link STRING, + time DATE, + FOREIGN KEY (userid) REFERENCES users(id) + ); + `, + + /* also known as forum categories; buckets of threads */ + ` + CREATE TABLE IF NOT EXISTS topics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT + ); + `, + /* thread link structure: <domain>.<tld>/thread/<id>/[<blurb>] */ + ` + CREATE TABLE IF NOT EXISTS threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + publishtime DATE, + topicid INTEGER, + authorid INTEGER, + FOREIGN KEY(topicid) REFERENCES topics(id), + FOREIGN KEY(authorid) REFERENCES users(id) + ); + `, + ` + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + publishtime DATE, + lastedit DATE, + authorid INTEGER, + threadid INTEGER, + FOREIGN KEY(authorid) REFERENCES users(id), + FOREIGN KEY(threadid) REFERENCES threads(id) + ); + `} + + for _, query := range queries { + if _, err := db.Exec(query); err != nil { + log.Fatalln(util.Eout(err, "creating database table %s", query)) + } + } +} + +/* goal for 2021-12-26 +* create thread +* create post +* get thread +* + html render of begotten thread + */ + +/* goal for 2021-12-28 +* in browser: reply on a thread +* in browser: create a new thread + */ +func (d DB) Exec(stmt string, args ...interface{}) (sql.Result, error) { + return d.db.Exec(stmt, args...) +} + +func (d DB) CreateThread(title, content string, authorid, topicid int) (int, error) { + ed := util.Describe("create thread") + // create the new thread in a transaction spanning two statements + tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options? + ed.Check(err, "start transaction") + // first, create the new thread + publish := time.Now() + threadStmt := `INSERT INTO threads (title, publishtime, topicid, authorid) VALUES (?, ?, ?, ?) + RETURNING id` + replyStmt := `INSERT INTO posts (content, publishtime, threadid, authorid) VALUES (?, ?, ?, ?)` + var threadid int + err = tx.QueryRow(threadStmt, title, publish, topicid, authorid).Scan(&threadid) + if err = ed.Eout(err, "add thread %s by %d in topic %d", title, authorid, topicid); err != nil { + _ = tx.Rollback() + log.Println(err, "rolling back") + return -1, err + } + // then add the content as the first reply to the thread + _, err = tx.Exec(replyStmt, content, publish, threadid, authorid) + if err = ed.Eout(err, "add initial reply for thread %d", threadid); err != nil { + _ = tx.Rollback() + log.Println(err, "rolling back") + return -1, err + } + err = tx.Commit() + ed.Check(err, "commit transaction") + // finally return the id of the created thread, so we can do a friendly redirect + return threadid, nil +} + +// c.f. +// https://medium.com/aubergine-solutions/how-i-handled-null-possible-values-from-database-rows-in-golang-521fb0ee267 +// type NullTime sql.NullTime +type Post struct { + ThreadTitle string + Content template.HTML + Author string + Publish time.Time + LastEdit sql.NullTime // TODO: handle json marshalling with custom type +} + +func (d DB) DeleteThread() {} +func (d DB) MoveThread() {} + +// TODO(2021-12-28): return error if non-existent thread +func (d DB) GetThread(threadid int) []Post { + // TODO: make edit work if no edit timestamp detected e.g. + // (sql: Scan error on column index 3, name "lastedit": unsupported Scan, storing driver.Value type <nil> into type + // *time.Time) + + // join with: + // users table to get user name + // threads table to get thread title + query := ` + SELECT t.title, content, u.name, 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 threadid = ? + ORDER BY p.publishtime + ` + stmt, err := d.db.Prepare(query) + util.Check(err, "get thread: prepare query") + defer stmt.Close() + + rows, err := stmt.Query(threadid) + util.Check(err, "get thread: query") + defer rows.Close() + + var data Post + var posts []Post + for rows.Next() { + if err := rows.Scan(&data.ThreadTitle, &data.Content, &data.Author, &data.Publish, &data.LastEdit); err != nil { + log.Fatalln(util.Eout(err, "get data for thread %d", threadid)) + } + posts = append(posts, data) + } + return posts +} + +type Thread struct { + Title string + Author string + Slug string + ID int + Publish time.Time +} + +// get a list of threads +func (d DB) ListThreads() []Thread { + query := ` + SELECT t.title, t.id, u.name FROM threads t + INNER JOIN users u on u.id = t.authorid + ORDER BY t.publishtime DESC + ` + stmt, err := d.db.Prepare(query) + util.Check(err, "list threads: prepare query") + defer stmt.Close() + + rows, err := stmt.Query() + util.Check(err, "list threads: query") + defer rows.Close() + + var data Thread + var threads []Thread + for rows.Next() { + if err := rows.Scan(&data.Title, &data.ID, &data.Author); err != nil { + log.Fatalln(util.Eout(err, "list threads: read in data via scan")) + } + data.Slug = fmt.Sprintf("%d/%s/", data.ID, util.SanitizeURL(data.Title)) + threads = append(threads, data) + } + return threads +} + +func (d DB) AddPost(content string, threadid, authorid int) { + stmt := `INSERT INTO posts (content, publishtime, threadid, authorid) VALUES (?, ?, ?, ?)` + publish := time.Now() + _, err := d.Exec(stmt, content, publish, threadid, authorid) + util.Check(err, "add post to thread %d (author %d)", threadid, authorid) +} + +func (d DB) EditPost(content string, postid int) { + stmt := `UPDATE posts set content = ?, lastedit = ? WHERE id = ?` + edit := time.Now() + _, err := d.Exec(stmt, content, edit, postid) + util.Check(err, "edit post %d", postid) +} + +func (d DB) DeletePost(postid int) { + stmt := `DELETE FROM posts WHERE id = ?` + _, err := d.Exec(stmt, postid) + util.Check(err, "deleting post %d", postid) +} + +func (d DB) CreateTopic(title, description string) { + stmt := `INSERT INTO topics (name, description) VALUES (?, ?)` + _, err := d.Exec(stmt, title, description) + util.Check(err, "creating topic %s", title) +} + +func (d DB) UpdateTopicName(topicid int, newname string) { + stmt := `UPDATE topics SET name = ? WHERE id = ?` + _, err := d.Exec(stmt, newname, topicid) + util.Check(err, "changing topic %d's name to %s", topicid, newname) +} + +func (d DB) UpdateTopicDescription(topicid int, newdesc string) { + stmt := `UPDATE topics SET description = ? WHERE id = ?` + _, err := d.Exec(stmt, newdesc, topicid) + util.Check(err, "changing topic %d's description to %s", topicid, newdesc) +} + +func (d DB) DeleteTopic(topicid int) { + stmt := `DELETE FROM topics WHERE id = ?` + _, err := d.Exec(stmt, topicid) + util.Check(err, "deleting topic %d", topicid) +} + +func (d DB) CreateUser(name, hash string) (int, error) { + stmt := `INSERT INTO users (name, passwordhash) VALUES (?, ?) RETURNING id` + var userid int + err := d.db.QueryRow(stmt, name, hash).Scan(&userid) + if err != nil { + return -1, util.Eout(err, "creating user %s", name) + } + return userid, nil +} + +func (d DB) GetPasswordHash(username string) (string, int, error) { + stmt := `SELECT passwordhash, id FROM users where name = ?` + var hash string + var userid int + err := d.db.QueryRow(stmt, username).Scan(&hash, &userid) + if err != nil { + return "", -1, util.Eout(err, "get password hash") + } + return hash, userid, nil +} + +func (d DB) existsQuery(substmt string, args ...interface{}) (bool, error) { + stmt := fmt.Sprintf(`SELECT exists (%s)`, substmt) + var exists bool + err := d.db.QueryRow(stmt, args...).Scan(&exists) + if err != nil { + return false, util.Eout(err, "exists: %s", substmt) + } + return exists, nil +} + +func (d DB) CheckUserExists(userid int) (bool, error) { + stmt := `SELECT 1 FROM users WHERE id = ?` + return d.existsQuery(stmt, userid) +} + +func (d DB) CheckUsernameExists(username string) (bool, error) { + stmt := `SELECT 1 FROM users WHERE name = ?` + return d.existsQuery(stmt, username) +} + +func (d DB) UpdateUserName(userid int, newname string) { + stmt := `UPDATE users SET name = ? WHERE id = ?` + _, err := d.Exec(stmt, newname, userid) + util.Check(err, "changing user %d's name to %s", userid, newname) +} + +func (d DB) UpdateUserPasswordHash(userid int, newhash string) { + stmt := `UPDATE users SET passwordhash = ? WHERE id = ?` + _, err := d.Exec(stmt, newhash, userid) + 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") + 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) AddRegistration(userid int, verificationLink string) error { + ed := util.Describe("add registration") + stmt := `INSERT INTO registrations (userid, host, link, time) VALUES (?, ?, ?, ?)` + t := time.Now() + u, err := url.Parse(verificationLink) + if err = ed.Eout(err, "parse url"); err != nil { + return err + } + _, err = d.Exec(stmt, userid, u.Host, verificationLink, t) + if err = ed.Eout(err, "add registration"); err != nil { + return err + } + return nil +} diff --git a/go.mod b/go.mod @@ -0,0 +1,15 @@ +module forum.cblgh.org + +go 1.16 + +require ( + github.com/carlmjohnson/requests v0.22.1 // indirect + github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df // indirect + github.com/gorilla/sessions v1.2.1 // indirect + github.com/mattn/go-sqlite3 v1.14.9 // indirect + github.com/microcosm-cc/bluemonday v1.0.17 // indirect + github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b // indirect + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect + golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d // indirect + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect +) diff --git a/go.sum b/go.sum @@ -0,0 +1,49 @@ +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= +github.com/carlmjohnson/requests v0.22.1/go.mod h1:Hw4fFOk3xDlHQbNRTGo4oc52TUTpVEq93sNy/H+mrQM= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df h1:M7mdNDTRraBcrHZg2aOYiFP9yTDajb6fquRZRpXnbVA= +github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= +github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= +github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b h1:Yu/2y+2iAAcTRfdlMZ3dEdb1aYWXesDDaQjb7xLgy7Y= +github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b/go.mod h1:RQlLg9p2W+/d3q6xRWilTA2R4ltKiwEmzoI1urnKm9U= +golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +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/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d h1:62NvYBuaanGXR2ZOfwDFkhhl6X1DUgf8qg3GuQvxZsE= +golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/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= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/html/about.html b/html/about.html @@ -0,0 +1,39 @@ +{{ template "head" . }} +<h2> About </h2> +<p>This forum is for and by the <a href="https://wiki.xxiivv.com/site/merveilles.html">Merveilles</a> community.</p> +<p>The <a href="https://github.com/cblgh/cerca">forum software</a> itself was created from scratch by <a href="https://cblgh.org">cblgh</a> at the start of 2022, after a long time +of pining for a new wave of forums hangs.</p> + +<p>If you are from Merveilles: <a href="/register">register</a> an account. If you're a passerby, feel free to read the <a href="/">public threads</a>.</p> + +<h3>Code of conduct</h3> +<p>As with all Merveilles spaces, this forum abides by the compact set out in the <a href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">Merveilles Code of Conduct</a>.</p> + +<h3>Forum syntax</h3> +<p>Posts in the forum are made using <a href="https://en.wikipedia.org/wiki/Markdown#Examples">Markdown + syntax</a>.</p> +<p><b>**Bold text**</b> and <i>*italics*</i></p> + +<ul> + <li>* lists</li> + <li>* like </li> + <li>* this </li> +</ul> + +<p><code>`typewriter text`</code></p> + +<pre><code>``` +blocks of +code like +this +``` +</code></pre> + +<p>Create links like <code>[this](url)</code>, and embed images like: <code>![description](url)</code>. Note how the image +syntax's exclamation mark precedes the regular link syntax.</p> + +<pre><code>this is one paragraph. +this belongs to the same paragraph. + +this is a new paragraph</code></pre> +{{ template "footer" . }} diff --git a/html/assets/favicon.png b/html/assets/favicon.png Binary files differ. diff --git a/html/assets/merveilles.svg b/html/assets/merveilles.svg @@ -0,0 +1,6 @@ +<svg width="30" height="30" fill="black" stroke="none" viewBox="60 60 200 200" xmlns="http://www.w3.org/2000/svg"> + <g> + <path d="M185,65 A60,60 0 0,0 125,125 L185,125 Z M125,245 A60,60 0 0,0 185,185 L125,185 Z M95,125 A30,30 0 0,1 125,155 A30,30 0 0,1 95,185 A30,30 0 0,1 65,155 A30,30 0 0,1 95,125 Z M155,125 A30,30 0 0,1 185,155 A30,30 0 0,1 155,185 A30,30 0 0,1 125,155 A30,30 0 0,1 155,125 Z M215,125 A30,30 0 0,1 245,155 A30,30 0 0,1 215,185 A30,30 0 0,1 185,155 A30,30 0 0,1 215,125 "/> + <path d="M125,65 A60,60 0 0,1 185,125 L125,125 Z M185,245 A60,60 0 0,1 125,185 L185,185 Z M65,65 A60,60 0 0,1 125,125 L65,125 Z M65,245 A60,60 0 0,0 125,185 L65,185 Z M245,65 A60,60 0 0,0 185,125 L245,125 Z M245,245 A60,60 0 0,1 185,185 L245,185 Z"/> + </g> +</svg> +\ No newline at end of file diff --git a/html/footer.html b/html/footer.html @@ -0,0 +1,4 @@ +{{ define "footer" }} + </body> +</html> +{{ end }} diff --git a/html/generic-message.html b/html/generic-message.html @@ -0,0 +1,6 @@ +{{ template "head" . }} +<h2>{{ .Data.Title }}</h2> +<p>{{ .Data.Message }}</p> +{{ if .Data.Link }} +<p>{{ .Data.LinkMessage }} <a href="{{.Data.Link}}">{{.Data.LinkText}}</a>.</p> +{{ end }} diff --git a/html/head.html b/html/head.html @@ -0,0 +1,102 @@ +{{ define "head" }} +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <title>Forum — {{ .Title }}</title> + + <style> + /* reset */ + *,*::after,*::before{box-sizing:border-box}blockquote,body,dd,dl,figure,h1,h2,h3,h4,p{margin:0}ul[role='list'],ol[role='list']{list-style:none}html:focus-within{scroll-behavior:smooth}body{min-height:100vh;text-rendering:optimizeSpeed;line-height:1.5}a:not([class]){text-decoration-skip-ink:auto}img,picture{max-width:100%;display:block}button,input,select,textarea{font:inherit}@media (prefers-reduced-motion: reduce){html:focus-within{scroll-behavior:auto}*,*::after,*::before{animation-duration:0.01ms !important;animation-iteration-count:1 !important;transition-duration:0.01ms !important;scroll-behavior:auto !important}} + /* end reset */ + /* below is the minimum viable css for simply getting the forum out the door; contributions, both in css and + design mockups, welcome from the community for v2,v3,...,vn :) */ + html { + max-width: 50rem; + font-family: sans-serif; + } + #logo { + width: 48px; + height: 48px; + display: block; + } + nav { + float: right; + } + ul[type="menu"] { + display: grid; + grid-template-columns: repeat(2, 1fr); + list-style-type: none; + margin: 0; + padding: 0; + grid-column-gap: 0.5rem; + } + li { margin-bottom: 0rem; } + ul { padding-left: 1rem; } + h1, h2 { margin-bottom: 1rem; } + p { margin-bottom: 1rem; } + div { margin-bottom: 2rem; } + textarea { min-height: 10rem; } + div > p:first-of-type, form div, label { margin-bottom: 0; } + h1 a:visited, + a:not([class]) { + color: black; + } + a:visited { + color: #666; /* for lack of better imagination */ + } + .selectable { + -webkit-touch-callout: all; + -webkit-user-select: all; + -khtml-user-select: all; + -moz-user-select: all; + -ms-user-select: all; + user-select: all; + } + pre { overflow: auto; } + .post-container { display: grid; max-width: 30rem; margin-top: 1rem; } + body { padding: 2rem; background: wheat; } + * { margin-bottom: 1rem; } + </style> + + <!-- <link href="/assets/style.css" rel="stylesheet"> --> + + <link rel="icon" href="/assets/favicon.png"> + <!-- <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml"> --> + <link rel="shortcut icon" href="/assets/favicon.png"> + <link rel="apple-touch-icon" href="/assets/favicon.png"> + <meta name="theme-color" content="#000000"> + + <!-- <meta name="description" content=""> --> + + <!-- <meta property="og:title" content=""> --> + <!-- <meta property="og:description" content=""> --> + <!-- <meta property="og:image" content="/assets/favicon.png"> --> + <!-- <meta property="og:image:alt" content=""> --> + <!-- <meta property="og:locale" content="en_US"> --> + <!-- <meta property="og:type" content="website"> --> + <!-- <meta name="twitter:card" content=""> --> + <!-- <meta property="og:url" content=""> --> + <!-- --> + <!-- <link rel="canonical" href=""> --> + + <!-- <link rel="search" type="application/opensearchdescription+xml" title="" href="/assets/opensearch.xml"> --> + </head> + <body> + <nav> + <ul type="menu"> + <li><a href="/about">about</a></li> + {{ if .LoggedIn }} + <li><a href="/logout">logout</a></li> + {{ else }} + <li><a href="/login">login</a></li> + {{ end }} + </ul> + </nav> + + <a href="/"> + <img src="/assets/merveilles.svg" id="logo"> + </a> +{{ end }} diff --git a/html/index.html b/html/index.html @@ -0,0 +1,8 @@ +{{ template "head" . }} +{{ range $index, $thread := .Data.Threads }} +<h2><a href="/thread/{{$thread.Slug}}">{{ $thread.Title }}</a></h2> +{{ end }} +{{ if .LoggedIn }} +<p> <a href="/thread/new">Start a new thread</a></p> +{{ end }} +{{ template "footer" . }} diff --git a/html/login-component.html b/html/login-component.html @@ -0,0 +1,9 @@ +{{ define "login-component" }} +<form method="post" action="/login"> + <div style="display: grid;"> + <input type="text" placeholder="username" name="username"> + <input type="password" placeholder="password" name="password"> + <input type="submit" value="Enter"> + </div> +</form> +{{ end }} diff --git a/html/login.html b/html/login.html @@ -0,0 +1,12 @@ +{{ template "head" . }} +<h2> Login </h2> +<div style="max-width: 20rem"> +{{ template "login-component" . }} +</div> +<p>This forum is for the <a href="https://wiki.xxiivv.com/site/merveilles.html">Merveilles</a> community. Don't have an account yet? <a href="/register">Register</a> one.</p> +{{ template "footer" . }} +{{ if .Data.FailedAttempt }} +<p> <b>Failed login attempt:</b> incorrect password, wrong username, or a non-existent user.</p> +{{ else if .LoggedIn }} +<p> You are already logged in. Would you like to <a href="/logout">log out</a>?</p> +{{ end }} diff --git a/html/new-thread.html b/html/new-thread.html @@ -0,0 +1,10 @@ +{{ template "head" . }} +<h2>Create thread</h2> + <form method="POST"> + <div class="post-container" > + <input required name="title" type="text" placeholder="Title"> + <textarea required name="content" placeholder="Tabula rasa"></textarea> + <button type="submit">Create</button> + </div> + </form> +{{ template "footer" . }} diff --git a/html/register-success.html b/html/register-success.html @@ -0,0 +1,16 @@ +{{ template "head" . }} +<h2> Register </h2> +<p>You now have an account! Welcome. Visit the <a href="/">index</a> to read and reply to threads, or start a new one.</p> +<p>There's just one more thing: <b>save the key displayed below</b>. It is a <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">keypair</a> +describing your forum identity, with a private part that only you know; the forum only stores the public portion.</p> + +<p>With this keypair you will be able to reset your account if you ever lose your password—and without having to share your email (or require +email infrastructure on the forum's part).</p> +<p><i><b>This keypair will only be displayed once</i></b></p> +<code> +<pre class="selectable"> +{{ .Data.Keypair }} +</pre> +</code> + +{{ template "footer" . }} diff --git a/html/register.html b/html/register.html @@ -0,0 +1,44 @@ +{{ template "head" . }} +<h2> Register </h2> +<form method="post"> + <input type="text" required placeholder="username" name="username"> + <input type="password" minlength="9" required placeholder="password" name="password"> + <input type="text" required placeholder="verification link" name="verificationlink"> + <input type="hidden" name="verificationcode" value="{{.Data.VerificationCode}}"> + <div> + <div> + <input type="checkbox" required id="coc"> + <label for="coc">I have refreshed my memory of the <a target="_blank" href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">Merveilles Code of Conduct</a></label> + </div> + <div> + <input type="checkbox" required id="coc2"> + <label for="coc2">Yes, I have actually <a target="_blank" href="https://github.com/merveilles/Resources/blob/master/CONDUCT.md">read it</a></label> + </div> + </div> + <input type="submit" value="Register"> +</form> + +<p>This forum is for the <a href="https://wiki.xxiivv.com/site/merveilles.html">Merveilles</a> community. To register, you need to either belong to the <a href="https://webring.xxiivv.com">Merveilles Webring</a> or the +<a href="https://merveilles.town">Merveilles Fediverse instance</a>. +</p> + +{{ if .Data.ErrorMessage }} +<div> + <p><b> {{ .Data.ErrorMessage }} </b></p> +</div> +{{ end }} + +<p>Your verification code is <b>{{ .Data.VerificationCode }}</b></p> +<details> + <summary>Verification instructions</summary> + <p>You can use either your mastodon profile or your webring site to verify your registration.</p> + <ul> + <li><b>Mastodon:</b> temporarily add a new metadata item to <a + href="https://merveilles.town/settings/profile">your profile</a> containing the verification code + displayed above. Pass your profile as the verification link.</li> + <li><b>Webring site:</b> Upload a plaintext file somewhere on your webring domain (incl. subdomain) containing + the verification code from above. Pass the link to the uploaded file as the verification link (make sure + it is viewable in a browser).</li> + </ul> +</details> +{{ template "footer" . }} diff --git a/html/thread.html b/html/thread.html @@ -0,0 +1,18 @@ +{{ template "head" . }} + <h2>{{ .Data.Title }}</h2> + {{ range $index, $post := .Data.Posts }} + <div> + <p><b>{{ $post.Author }}</b></p> + {{ $post.Content }} + </div> + {{ end }} + {{ if .LoggedIn }} + <div> + <form method="POST"> + <div class="post-container" > + <textarea required name="content" placeholder="Tabula rasa"></textarea> + <button type="submit">Post</button> + </div> + </form> + {{ end }} +{{ template "footer" . }} diff --git a/run.go b/run.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "fmt" + "forum.cblgh.org/server" + "forum.cblgh.org/util" + "os" + "strings" +) + +func readAllowlist(location string) []string { + ed := util.Describe("read allowlist") + data, err := os.ReadFile(location) + ed.Check(err, "read file") + list := strings.Split(strings.TrimSpace(string(data)), "\n") + for i, fullpath := range list { + list[i] = strings.TrimPrefix(strings.TrimPrefix(fullpath, "https://"), "http://") + } + return list +} + +func complain(msg string) { + fmt.Printf("cerca: %s\n", msg) + os.Exit(0) +} + +func main() { + // TODO (2022-01-10): somehow continually update veri sites by scraping merveilles webring sites || webring domain + var allowlistLocation string + var sessionKey string + var dev bool + flag.BoolVar(&dev, "dev", false, "trigger development mode") + flag.StringVar(&allowlistLocation, "allowlist", "", "domains which can be used to read verification codes from during registration") + flag.StringVar(&sessionKey, "authkey", "", "session cookies authentication key") + flag.Parse() + if len(sessionKey) == 0 { + complain("please pass a random session auth key with --authkey") + } else if len(allowlistLocation) == 0 { + complain("please pass a file containing the verification code domain allowlist") + } + allowlist := readAllowlist(allowlistLocation) + allowlist = append(allowlist, "merveilles.town") + server.Serve(allowlist, sessionKey, dev) +} diff --git a/server/server.go b/server/server.go @@ -0,0 +1,437 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "syscall" + + "cerca/crypto" + "cerca/database" + "cerca/server/session" + "cerca/util" + "html/template" + + "github.com/carlmjohnson/requests" +) + +/* TODO (2022-01-03): include csrf token via gorilla, or w/e, when rendering */ + +type TemplateData struct { + Data interface{} + LoggedIn bool // TODO (2022-01-09): put this in a middleware || template function or sth? + Title string +} + +type IndexData struct { + Threads []database.Thread +} + +type GenericMessageData struct { + Title string + Message string + LinkMessage string + Link string + LinkText string +} + +type RegisterData struct { + VerificationCode string + ErrorMessage string +} + +type RegisterSuccessData struct { + Keypair string +} + +type LoginData struct { + FailedAttempt bool +} + +type ThreadData struct { + Title string + Posts []database.Post +} + +type RequestHandler struct { + db *database.DB + session *session.Session + allowlist []string // allowlist of domains valid for forum registration +} + +var developing bool + +func dump(err error) { + if developing { + fmt.Println(err) + } +} + +// returns true if logged in, and the userid of the logged in user. +// returns false (and userid set to -1) if not logged in +func (h RequestHandler) IsLoggedIn(req *http.Request) (bool, int) { + ed := util.Describe("IsLoggedIn") + 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 + } + return true, userid +} + +var views = []string{"index", "head", "footer", "login-component", "login", "register", "register-success", "thread", "new-thread", "generic-message", "about"} + +// wrap the contents of `views` to the format expected by template.ParseFiles() +func wrapViews() []string { + for i, item := range views { + views[i] = fmt.Sprintf("html/%s.html", item) + } + return views +} + +var templates = template.Must(template.ParseFiles(wrapViews()...)) + +func (h RequestHandler) renderView(res http.ResponseWriter, viewName string, data TemplateData) { + if data.Title == "" { + data.Title = strings.ReplaceAll(viewName, "-", " ") + } + errTemp := templates.ExecuteTemplate(res, viewName+".html", data) + if errors.Is(errTemp, syscall.EPIPE) { + fmt.Println("had a broken pipe, continuing") + } else { + util.Check(errTemp, "rendering %s view", viewName) + } +} + +func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) { + ed := util.Describe("thread route") + parts := strings.Split(strings.TrimSpace(req.URL.Path), "/") + // invalid route, redirect to index + if len(parts) < 2 || parts[2] == "" { + IndexRedirect(res, req) + return + } + loggedIn, userid := h.IsLoggedIn(req) + + threadid, err := strconv.Atoi(parts[2]) + ed.Check(err, "parse %s as id slug", parts[2]) + + if req.Method == "POST" && loggedIn { + // handle POST (=> add a reply, then show the thread) + content := req.PostFormValue("content") + // TODO (2022-01-09): make sure rendered content won't be empty after sanitizing: + // * run sanitize step && strings.TrimSpace and check length **before** doing AddPost + // TODO(2022-01-09): send errors back to thread's posting view + h.db.AddPost(content, threadid, userid) + } + // after handling a post, treat the request as if it was a get request + // TODO (2022-01-07): + // * handle error + thread := h.db.GetThread(threadid) + // markdownize content (but not title) + for i, post := range thread { + thread[i].Content = util.Markup(post.Content) + } + title := thread[0].ThreadTitle + view := TemplateData{ThreadData{title, thread}, loggedIn, title} + h.renderView(res, "thread", view) +} + +func (h RequestHandler) ErrorRoute(res http.ResponseWriter, req *http.Request, status int) { + title := "Page not found" + data := GenericMessageData{ + Title: title, + Message: fmt.Sprintf("The visited page does not exist (anymore?). Error code %d.", status), + } + h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) +} + +func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { + // handle 404 + if req.URL.Path != "/" { + h.ErrorRoute(res, req, http.StatusNotFound) + return + } + loggedIn, _ := h.IsLoggedIn(req) + // show index listing + threads := h.db.ListThreads() + view := TemplateData{IndexData{threads}, loggedIn, "threads"} + h.renderView(res, "index", view) +} + +func IndexRedirect(res http.ResponseWriter, req *http.Request) { + http.Redirect(res, req, "/", http.StatusSeeOther) +} + +func (h RequestHandler) LogoutRoute(res http.ResponseWriter, req *http.Request) { + loggedIn, _ := h.IsLoggedIn(req) + if loggedIn { + h.session.Delete(res, req) + } + IndexRedirect(res, req) +} + +func (h RequestHandler) LoginRoute(res http.ResponseWriter, req *http.Request) { + ed := util.Describe("LoginRoute") + loggedIn, _ := h.IsLoggedIn(req) + switch req.Method { + case "GET": + h.renderView(res, "login", TemplateData{LoginData{}, loggedIn, ""}) + case "POST": + username := req.PostFormValue("username") + password := req.PostFormValue("password") + // * hash received password and compare to stored hash + passwordHash, userid, err := h.db.GetPasswordHash(username) + // make sure user exists + if err = ed.Eout(err, "getting password hash and uid"); err != nil { + fmt.Println(err) + h.renderView(res, "login", TemplateData{LoginData{FailedAttempt: true}, loggedIn, ""}) + IndexRedirect(res, req) + return + } + if !crypto.ValidatePasswordHash(password, passwordHash) { + fmt.Println("incorrect password!") + h.renderView(res, "login", TemplateData{LoginData{FailedAttempt: true}, loggedIn, ""}) + return + } + // save user id in cookie + err = h.session.Save(req, res, userid) + ed.Check(err, "saving session cookie") + IndexRedirect(res, req) + default: + fmt.Println("non get/post method, redirecting to index") + IndexRedirect(res, req) + } +} + +// downloads the content at the verification link and compares it to the verification code. returns true if the verification link content contains the verification code somewhere +func hasVerificationCode(link, verification string) bool { + var linkBody string + err := requests. + URL(link). + ToString(&linkBody). + Fetch(context.Background()) + if err != nil { + fmt.Println(util.Eout(err, "HasVerificationCode")) + return false + } + + return strings.Contains(strings.TrimSpace(linkBody), strings.TrimSpace(verification)) +} + +func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request) { + ed := util.Describe("register route") + loggedIn, _ := h.IsLoggedIn(req) + errMessage := "" + if loggedIn { + data := GenericMessageData{ + Title: "Register", + Message: "You already have an account (you are logged in with it).", + Link: "/", + LinkMessage: "Visit the", + LinkText: "index", + } + h.renderView(res, "generic-message", TemplateData{data, loggedIn, "register"}) + return + } + + renderErr := func(verificationCode, errMessage string) { + fmt.Println(errMessage) + h.renderView(res, "register", TemplateData{Data: RegisterData{verificationCode, errMessage}}) + } + switch req.Method { + case "GET": + // try to get the verification code from the session (useful in case someone refreshed the page) + verificationCode, err := h.session.GetVerificationCode(req) + // we had an error getting the verification code, generate a code and set it on the session + if err != nil { + verificationCode = fmt.Sprintf("MRV%06d\n", crypto.GenerateVerificationCode()) + err = h.session.SaveVerificationCode(req, res, verificationCode) + if err != nil { + errMessage = "Had troubles setting the verification code on session" + renderErr(verificationCode, errMessage) + return + } + } + h.renderView(res, "register", TemplateData{Data: RegisterData{verificationCode, ""}}) + case "POST": + verificationCode, err := h.session.GetVerificationCode(req) + if err != nil { + errMessage = "There was no verification record for this browser session; missing data to compare against verification link content" + renderErr(verificationCode, errMessage) + return + } + 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 { + errMessage = "Had troubles parsing the verification link, are you sure it was a proper url?" + renderErr(verificationCode, errMessage) + 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)) + errMessage = fmt.Sprintf("Verification link's host (%s) is not in the allowlist", u.Host) + renderErr(verificationCode, errMessage) + return + } + + // parse out verification code from verification link and compare against verification code in session + has := hasVerificationCode(verificationLink, verificationCode) + if !has { + errMessage = fmt.Sprintf("Verification code from link (%s) does not match", verificationLink) + renderErr(verificationCode, errMessage) + return + } + // make sure username is not registered already + exists, err := h.db.CheckUsernameExists(username) + if err != nil { + errMessage = "Database had a problem when checking username" + renderErr(verificationCode, errMessage) + return + } + if exists { + errMessage = fmt.Sprintf("Username %s appears to already exist, please pick another name", username) + renderErr(verificationCode, errMessage) + return + } + hash, err := crypto.HashPassword(password) + if err != nil { + fmt.Println(ed.Eout(err, "hash password")) + errMessage = "Database had a problem when hashing password" + renderErr(verificationCode, errMessage) + return + } + userid, err := h.db.CreateUser(username, hash) + if err != nil { + errMessage = "Error in db when creating user" + renderErr(verificationCode, errMessage) + return + } + // log the new user in + h.session.Save(req, res, userid) + // log where the registration is coming from, in the case of indirect invites && for curiosity + err = h.db.AddRegistration(userid, verificationLink) + if err = ed.Eout(err, "add registration"); err != nil { + dump(err) + } + // generate and pass public keypair + keypair, err := crypto.GenerateKeypair() + // record generated pubkey in database for eventual later use + err = h.db.AddPubkey(userid, keypair.Public) + if err = ed.Eout(err, "insert pubkey in db"); err != nil { + dump(err) + } + ed.Check(err, "generate keypair") + kpJson, err := keypair.Marshal() + ed.Check(err, "marshal keypair") + h.renderView(res, "register-success", TemplateData{RegisterSuccessData{string(kpJson)}, loggedIn, "registered successfully"}) + default: + fmt.Println("non get/post method, redirecting to index") + IndexRedirect(res, req) + } +} + +func (h RequestHandler) GenericRoute(res http.ResponseWriter, req *http.Request) { + data := GenericMessageData{ + Title: "GenericTitle", + Message: "Generic message", + Link: "/", + LinkMessage: "Generic link messsage", + LinkText: "with link", + } + h.renderView(res, "generic-message", TemplateData{Data: data}) +} + +func (h RequestHandler) AboutRoute(res http.ResponseWriter, req *http.Request) { + loggedIn, _ := h.IsLoggedIn(req) + h.renderView(res, "about", TemplateData{LoggedIn: loggedIn}) +} + +func (h RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Request) { + loggedIn, userid := h.IsLoggedIn(req) + switch req.Method { + // Handle GET (=> want to start a new thread) + case "GET": + if !loggedIn { + title := "Not logged in" + data := GenericMessageData{ + Title: title, + Message: "Only members of this forum may create new threads", + Link: "/login", + LinkMessage: "If you are a member,", + LinkText: "log in", + } + h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) + return + } + h.renderView(res, "new-thread", TemplateData{LoggedIn: loggedIn, Title: "new thread"}) + case "POST": + // Handle POST (=> + title := req.PostFormValue("title") + content := req.PostFormValue("content") + // TODO (2022-01-10): unstub topicid, once we have other topics :) + // the new thread was created: forward info to database + threadid, err := h.db.CreateThread(title, content, userid, 1) + if err != nil { + data := GenericMessageData{ + Title: "Error creating thread", + Message: "There was a database error when creating the thread, apologies.", + } + h.renderView(res, "generic-message", TemplateData{Data: data, Title: "new thread"}) + return + } + // when data has been stored => redirect to thread + slug := fmt.Sprintf("thread/%d/%s/", threadid, util.SanitizeURL(title)) + http.Redirect(res, req, "/"+slug, http.StatusSeeOther) + default: + fmt.Println("non get/post method, redirecting to index") + IndexRedirect(res, req) + } +} + +func Serve(allowlist []string, sessionKey string, isdev bool) { + port := ":8272" + dbpath := "./data/forum.db" + if isdev { + developing = true + dbpath = "./data/forum.test.db" + port = ":8277" + } + + db := database.InitDB(dbpath) + handler := RequestHandler{&db, session.New(sessionKey, developing), allowlist} + /* 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 + http.HandleFunc("/about", handler.AboutRoute) + http.HandleFunc("/logout", handler.LogoutRoute) + http.HandleFunc("/login", handler.LoginRoute) + http.HandleFunc("/register", handler.RegisterRoute) + http.HandleFunc("/thread/new/", handler.NewThreadRoute) + http.HandleFunc("/thread/", handler.ThreadRoute) + http.HandleFunc("/", handler.IndexRoute) + + fileserver := http.FileServer(http.Dir("html/assets/")) + http.Handle("/assets/", http.StripPrefix("/assets/", fileserver)) + + fmt.Println("Serving forum on", port) + http.ListenAndServe(port, nil) +} diff --git a/server/session/session.go b/server/session/session.go @@ -0,0 +1,122 @@ +package session + +/* +Copyright (c) 2019 m15o <m15o@posteo.net> . All rights reserved. +Copyright (c) 2022 cblgh <m15o@posteo.net> . All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +import ( + "errors" + "fmt" + "forum.cblgh.org/util" + "net/http" + + "github.com/gorilla/sessions" +) + +const cookieName = "cerca" + +type Session struct { + Store *sessions.CookieStore + ShortLivedStore *sessions.CookieStore +} + +func New(authKey string, developing bool) *Session { + store := sessions.NewCookieStore([]byte(authKey)) + store.Options = &sessions.Options{ + HttpOnly: true, + Secure: !developing, + MaxAge: 86400 * 30, + } + short := sessions.NewCookieStore([]byte(authKey)) + short.Options = &sessions.Options{ + HttpOnly: true, + // Secure: true, // TODO (2022-01-05): uncomment when served over https + MaxAge: 600, // 10 minutes + } + return &Session{ + Store: store, + ShortLivedStore: short, + } +} + +func (s *Session) Delete(res http.ResponseWriter, req *http.Request) error { + ed := util.Describe("delete session cookie") + clearSession := func(store *sessions.CookieStore) error { + session, err := store.Get(req, cookieName) + if err != nil { + return ed.Eout(err, "get session") + } + session.Options.MaxAge = -1 + err = session.Save(req, res) + return ed.Eout(err, "save expired session") + } + err := clearSession(s.Store) + if err != nil { + return err + } + err = clearSession(s.ShortLivedStore) + return err +} + +func getValueFromSession(req *http.Request, store *sessions.CookieStore, key string) (interface{}, error) { + session, err := store.Get(req, cookieName) + if err != nil { + return nil, err + } + value, ok := session.Values[key] + if !ok { + err := errors.New(fmt.Sprintf("extracting %s from session; no such value", key)) + return nil, util.Eout(err, "get session") + } + return value, nil +} + +func (s *Session) GetVerificationCode(req *http.Request) (string, error) { + val, err := getValueFromSession(req, s.ShortLivedStore, "verificationCode") + if val == nil || err != nil { + return "", err + } + return val.(string), err +} + +func (s *Session) Get(req *http.Request) (int, error) { + val, err := getValueFromSession(req, s.Store, "userid") + if val == nil || err != nil { + return -1, err + } + return val.(int), err +} + +func (s *Session) Save(req *http.Request, res http.ResponseWriter, userid int) error { + session, _ := s.Store.Get(req, cookieName) + session.Values["userid"] = userid + return session.Save(req, res) +} + +func (s *Session) SaveVerificationCode(req *http.Request, res http.ResponseWriter, code string) error { + session, _ := s.ShortLivedStore.Get(req, cookieName) + session.Values["verificationCode"] = code + return session.Save(req, res) +} diff --git a/util/util.go b/util/util.go @@ -0,0 +1,100 @@ +package util + +import ( + "fmt" + "html/template" + "log" + "net/url" + "strings" + + "github.com/gomarkdown/markdown" + "github.com/microcosm-cc/bluemonday" + // "errors" +) + +/* util.Eout example invocations +if err != nil { + return util.Eout(err, "reading data") +} +if err = util.Eout(err, "reading data"); err != nil { + return nil, err +} +*/ + +type ErrorDescriber struct { + environ string // the basic context that is potentially generating errors (like a GetThread function, the environ would be "get thread") +} + +// parametrize Eout/Check such that error messages contain a defined context/environ +func Describe(environ string) ErrorDescriber { + return ErrorDescriber{environ} +} + +func (ed ErrorDescriber) Eout(err error, msg string, args ...interface{}) error { + msg = fmt.Sprintf("%s: %s", ed.environ, msg) + return Eout(err, msg, args...) +} + +func (ed ErrorDescriber) Check(err error, msg string, args ...interface{}) { + msg = fmt.Sprintf("%s: %s", ed.environ, msg) + Check(err, msg, args...) +} + +// format all errors consistently, and provide context for the error using the string `msg` +func Eout(err error, msg string, args ...interface{}) error { + if err != nil { + // received an invocation of e.g. format: + // Eout(err, "reading data for %s and %s", "database item", "weird user") + if len(args) > 0 { + return fmt.Errorf("%s (%w)", fmt.Sprintf(msg, args...), err) + } + return fmt.Errorf("%s (%w)", msg, err) + } + return nil +} + +func Check(err error, msg string, args ...interface{}) { + if len(args) > 0 { + err = Eout(err, msg, args...) + } else { + err = Eout(err, msg) + } + if err != nil { + log.Fatalln(err) + } +} + +func Contains(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +var contentGuardian = bluemonday.UGCPolicy() +var strictContentGuardian = bluemonday.StrictPolicy() + +// Turns Markdown input into HTML +func Markup(md template.HTML) template.HTML { + mdBytes := []byte(string(md)) + // fix newlines + mdBytes = markdown.NormalizeNewlines(mdBytes) + maybeUnsafeHTML := markdown.ToHTML(mdBytes, nil, nil) + // guard against malicious code being embedded + html := contentGuardian.SanitizeBytes(maybeUnsafeHTML) + return template.HTML(html) +} + +func SanitizeStringStrict(s string) string { + return strictContentGuardian.Sanitize(s) +} + +// make a string be suitable for use as part of a url +func SanitizeURL(input string) string { + input = strings.ReplaceAll(input, " ", "-") + input = url.PathEscape(input) + // TODO(2022-01-08): evaluate use of strict content guardian? + return strings.ToLower(input) +}