cerca

lean forum software (pmc local branch)
git clone http://git.permacomputing.net/repos/cerca.git # read-only access
Log | Files | Refs | README | LICENSE

database.go (16162B)


      1 package database
      2 
      3 import (
      4 	"cerca/crypto"
      5 	"context"
      6 	"database/sql"
      7 	"errors"
      8 	"fmt"
      9 	"log"
     10 	"net/url"
     11 	"os"
     12 	"time"
     13 
     14 	"cerca/util"
     15 
     16 	_ "github.com/mattn/go-sqlite3"
     17 )
     18 
     19 type DB struct {
     20 	db *sql.DB
     21 }
     22 
     23 func CheckExists(filepath string) bool {
     24 	if _, err := os.Stat(filepath); errors.Is(err, os.ErrNotExist) {
     25 		return false
     26 	}
     27 	return true
     28 }
     29 
     30 func InitDB(filepath string) DB {
     31 	exists := CheckExists(filepath)
     32 	if !exists {
     33 		file, err := os.Create(filepath)
     34 		if err != nil {
     35 			log.Fatal(err)
     36 		}
     37 		defer file.Close()
     38 	}
     39 
     40 	db, err := sql.Open("sqlite3", filepath)
     41 	util.Check(err, "opening sqlite3 database at %s", filepath)
     42 	if db == nil {
     43 		log.Fatalln("db is nil")
     44 	}
     45 	createTables(db)
     46 	instance := DB{db}
     47 	instance.makeSureDefaultUsersExist()
     48 	return instance
     49 }
     50 
     51 const DELETED_USER_NAME = "deleted user"
     52 const SYSTEM_USER_NAME = "CERCA_CMD"
     53 
     54 func (d DB) makeSureDefaultUsersExist() {
     55 	ed := util.Describe("create default users")
     56 	for _, defaultUser := range []string{DELETED_USER_NAME, SYSTEM_USER_NAME} {
     57 		userExists, err := d.CheckUsernameExists(defaultUser)
     58 		if err != nil {
     59 			log.Fatalln(ed.Eout(err, "check username for %s exists", defaultUser))
     60 		}
     61 		if !userExists {
     62 			passwordHash, err := crypto.HashPassword(crypto.GeneratePassword())
     63 			_, err = d.CreateUser(defaultUser, passwordHash)
     64 			if err != nil {
     65 				log.Fatalln(ed.Eout(err, "create %s", defaultUser))
     66 			}
     67 		}
     68 	}
     69 }
     70 
     71 func createTables(db *sql.DB) {
     72 	// create the table if it doesn't exist
     73 	queries := []string{
     74 		/* used for versioning migrations */
     75 		`
     76   CREATE TABLE IF NOT EXISTS meta (
     77     schemaversion INTEGER NOT NULL
     78   );
     79   `,
     80 		`
     81   CREATE TABLE IF NOT EXISTS users (
     82     id INTEGER PRIMARY KEY AUTOINCREMENT,
     83     name TEXT NOT NULL UNIQUE,
     84     passwordhash TEXT NOT NULL
     85   );
     86   `,
     87 		`
     88   CREATE TABLE IF NOT EXISTS admins(
     89     id INTEGER PRIMARY KEY
     90   );
     91   `,
     92 		/* add optional columns: quorumuser quorum_action (confirm, veto)? */
     93 		`
     94   CREATE TABLE IF NOT EXISTS moderation_log (
     95     id INTEGER PRIMARY KEY AUTOINCREMENT,
     96 		actingid INTEGER NOT NULL,
     97 		recipientid INTEGER,
     98 		action INTEGER NOT NULL,
     99     time DATE NOT NULL,
    100 
    101     FOREIGN KEY (actingid) REFERENCES users(id),
    102     FOREIGN KEY (recipientid) REFERENCES users(id)
    103   );
    104   `,
    105 		`
    106 	CREATE TABLE IF NOT EXISTS quorum_decisions (
    107 		userid INTEGER NOT NULL,
    108 		decision BOOL NOT NULL,
    109 		modlogid INTEGER NOT NULL,
    110 
    111 		FOREIGN KEY (modlogid) REFERENCES moderation_log(id)
    112 	);
    113 	`,
    114 		`
    115 	CREATE TABLE IF NOT EXISTS moderation_proposals (
    116 		id INTEGER PRIMARY KEY AUTOINCREMENT,
    117 		proposerid INTEGER NOT NULL,
    118 		recipientid INTEGER NOT NULL,
    119 		action INTEGER NOT NULL,
    120 		time DATE NOT NULL,
    121 
    122 		FOREIGN KEY (proposerid) REFERENCES users(id),
    123 		FOREIGN KEY (recipientid) REFERENCES users(id)
    124 	);
    125 		`,
    126 		`
    127   CREATE TABLE IF NOT EXISTS registrations (
    128     id INTEGER PRIMARY KEY AUTOINCREMENT,
    129     userid INTEGER,
    130     host STRING,
    131     link STRING,
    132     time DATE,
    133     FOREIGN KEY (userid) REFERENCES users(id)
    134   );
    135   `,
    136 
    137 		/* also known as forum categories; buckets of threads */
    138 		`
    139   CREATE TABLE IF NOT EXISTS topics (
    140     id INTEGER PRIMARY KEY AUTOINCREMENT,
    141     name TEXT NOT NULL UNIQUE,
    142     description TEXT
    143   );
    144   `,
    145 		/* thread link structure: <domain>.<tld>/thread/<id>/[<blurb>] */
    146 		`
    147   CREATE TABLE IF NOT EXISTS threads (
    148     id INTEGER PRIMARY KEY AUTOINCREMENT,
    149     title TEXT NOT NULL,
    150     publishtime DATE,
    151     topicid INTEGER,
    152     authorid INTEGER,
    153     private INTEGER NOT NULL DEFAULT 0,
    154     FOREIGN KEY(topicid) REFERENCES topics(id),
    155     FOREIGN KEY(authorid) REFERENCES users(id)
    156   );
    157   `,
    158 		`
    159   CREATE TABLE IF NOT EXISTS posts (
    160     id INTEGER PRIMARY KEY AUTOINCREMENT,
    161     content TEXT NOT NULL,
    162     publishtime DATE,
    163     lastedit DATE,
    164     authorid INTEGER,
    165     threadid INTEGER,
    166     FOREIGN KEY(authorid) REFERENCES users(id),
    167     FOREIGN KEY(threadid) REFERENCES threads(id)
    168   );
    169   `}
    170 
    171 	for _, query := range queries {
    172 		if _, err := db.Exec(query); err != nil {
    173 			log.Fatalln(util.Eout(err, "creating database table %s", query))
    174 		}
    175 	}
    176 }
    177 
    178 /* goal for 2021-12-26
    179 * create thread
    180 * create post
    181 * get thread
    182 * + html render of begotten thread
    183  */
    184 
    185 /* goal for 2021-12-28
    186 * in browser: reply on a thread
    187 * in browser: create a new thread
    188  */
    189 func (d DB) Exec(stmt string, args ...interface{}) (sql.Result, error) {
    190 	return d.db.Exec(stmt, args...)
    191 }
    192 
    193 func (d DB) CreateThread(title, content string, authorid, topicid int, isPrivate bool) (int, error) {
    194 	ed := util.Describe("create thread")
    195 	// create the new thread in a transaction spanning two statements
    196 	tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options?
    197 	ed.Check(err, "start transaction")
    198 	// first, create the new thread
    199 	publish := time.Now()
    200 	threadStmt := `INSERT INTO threads (title, publishtime, topicid, authorid, private) VALUES (?, ?, ?, ?, ?)
    201   RETURNING id`
    202 	replyStmt := `INSERT INTO posts (content, publishtime, threadid, authorid) VALUES (?, ?, ?, ?)`
    203 	var threadid int
    204 	private := 0
    205 	if isPrivate {
    206 		private = 1
    207 	}
    208 	err = tx.QueryRow(threadStmt, title, publish, topicid, authorid, private).Scan(&threadid)
    209 	if err = ed.Eout(err, "add thread %s (private: %d) by %d in topic %d", title, private, authorid, topicid); err != nil {
    210 		_ = tx.Rollback()
    211 		log.Println(err, "rolling back")
    212 		return -1, err
    213 	}
    214 	// then add the content as the first reply to the thread
    215 	_, err = tx.Exec(replyStmt, content, publish, threadid, authorid)
    216 	if err = ed.Eout(err, "add initial reply for thread %d", threadid); err != nil {
    217 		_ = tx.Rollback()
    218 		log.Println(err, "rolling back")
    219 		return -1, err
    220 	}
    221 	err = tx.Commit()
    222 	ed.Check(err, "commit transaction")
    223 	// finally return the id of the created thread, so we can do a friendly redirect
    224 	return threadid, nil
    225 }
    226 
    227 // c.f.
    228 // https://medium.com/aubergine-solutions/how-i-handled-null-possible-values-from-database-rows-in-golang-521fb0ee267
    229 // type NullTime sql.NullTime
    230 type Post struct {
    231 	ID          int
    232 	ThreadTitle string
    233 	ThreadID    int
    234 	Content     string // markdown
    235 	Author      string
    236 	AuthorID    int
    237 	Publish     time.Time
    238 	LastEdit    sql.NullTime // TODO: handle json marshalling with custom type
    239 }
    240 
    241 func (d DB) DeleteThread() {}
    242 func (d DB) MoveThread()   {}
    243 
    244 // TODO(2021-12-28): return error if non-existent thread
    245 func (d DB) GetThread(threadid int) ([]Post, error) {
    246 	// TODO: make edit work if no edit timestamp detected e.g.
    247 	// (sql: Scan error on column index 3, name "lastedit": unsupported Scan, storing driver.Value type <nil> into type
    248 	// *time.Time)
    249 
    250 	exists, err := d.CheckThreadExists(threadid)
    251 	if err != nil || !exists {
    252 		return []Post{}, errors.New(fmt.Sprintf("GetThread: threadid %d did not exist", threadid))
    253 	}
    254 	// join with:
    255 	//    users table to get user name
    256 	//    threads table to get thread title
    257 	query := `
    258   SELECT p.id, t.title, content, u.name, p.authorid, p.publishtime, p.lastedit
    259   FROM posts p 
    260   INNER JOIN users u ON u.id = p.authorid 
    261   INNER JOIN threads t ON t.id = p.threadid
    262   WHERE threadid = ? 
    263   ORDER BY p.publishtime
    264   `
    265 	stmt, err := d.db.Prepare(query)
    266 	util.Check(err, "get thread: prepare query")
    267 	defer stmt.Close()
    268 
    269 	rows, err := stmt.Query(threadid)
    270 	util.Check(err, "get thread: query")
    271 	defer rows.Close()
    272 
    273 	var data Post
    274 	var posts []Post
    275 	for rows.Next() {
    276 		if err := rows.Scan(&data.ID, &data.ThreadTitle, &data.Content, &data.Author, &data.AuthorID, &data.Publish, &data.LastEdit); err != nil {
    277 			log.Fatalln(util.Eout(err, "get data for thread %d", threadid))
    278 		}
    279 		posts = append(posts, data)
    280 	}
    281 	return posts, nil
    282 }
    283 
    284 func (d DB) GetPost(postid int) (Post, error) {
    285 	stmt := `
    286   SELECT p.id, t.title, t.id, content, u.name, p.authorid, p.publishtime, p.lastedit
    287   FROM posts p 
    288   INNER JOIN users u ON u.id = p.authorid 
    289   INNER JOIN threads t ON t.id = p.threadid
    290   WHERE p.id = ?
    291   `
    292 	var data Post
    293 	err := d.db.QueryRow(stmt, postid).Scan(&data.ID, &data.ThreadTitle, &data.ThreadID, &data.Content, &data.Author, &data.AuthorID, &data.Publish, &data.LastEdit)
    294 	err = util.Eout(err, "get data for thread %d", postid)
    295 	return data, err
    296 }
    297 
    298 type Thread struct {
    299 	Title   string
    300 	Author  string
    301 	Slug    string
    302 	Private bool
    303 	ID      int
    304 	Publish time.Time
    305 	PostID  int
    306 }
    307 
    308 // get a list of threads
    309 // NOTE: this query is setting thread.Author not by thread creator, but latest poster. if this becomes a problem, revert
    310 // its use and employ Thread.PostID to perform another query for each thread to get the post author name (wrt server.go:GenerateRSS)
    311 func (d DB) ListThreads(sortByPost bool, includePrivate bool) []Thread {
    312 	query := `
    313   SELECT count(t.id), t.title, t.id, t.private, u.name, p.publishtime, p.id FROM threads t
    314   INNER JOIN users u on u.id = p.authorid
    315   INNER JOIN posts p ON t.id = p.threadid
    316   %s
    317   GROUP BY t.id
    318   %s
    319   `
    320 	orderBy := `ORDER BY t.publishtime DESC`
    321 	// get a list of threads by ordering them based on most recent post
    322 	if sortByPost {
    323 		orderBy = `ORDER BY max(p.id) DESC`
    324 	}
    325 	where := `WHERE t.private = 0`
    326 	if includePrivate {
    327 		where = `WHERE t.private IN (0,1)`
    328 	}
    329 	query = fmt.Sprintf(query, where, orderBy)
    330 
    331 	stmt, err := d.db.Prepare(query)
    332 	util.Check(err, "list threads: prepare query")
    333 	defer stmt.Close()
    334 
    335 	rows, err := stmt.Query()
    336 	util.Check(err, "list threads: query")
    337 	defer rows.Close()
    338 
    339 	var postCount int
    340 	var data Thread
    341 	var isPrivate int
    342 	var threads []Thread
    343 	for rows.Next() {
    344 		if err := rows.Scan(&postCount, &data.Title, &data.ID, &isPrivate, &data.Author, &data.Publish, &data.PostID); err != nil {
    345 			log.Fatalln(util.Eout(err, "list threads: read in data via scan"))
    346 		}
    347 		data.Private = (isPrivate == 1)
    348 		data.Slug = util.GetThreadSlug(data.ID, data.Title, postCount)
    349 		threads = append(threads, data)
    350 	}
    351 	return threads
    352 }
    353 
    354 func (d DB) IsThreadPrivate(threadid int) (bool, error) {
    355 	exists, err := d.CheckThreadExists(threadid)
    356 
    357 	if err != nil || !exists {
    358 		return true, errors.New(fmt.Sprintf("IsThreadPrivate: threadid %d did not exist", threadid))
    359 	}
    360 
    361 	var private int
    362 	stmt := `SELECT private FROM threads where id = ?`
    363 	err = d.db.QueryRow(stmt, threadid).Scan(&private)
    364 	util.Check(err, "querying if private thread %d", threadid)
    365 	return private == 1, nil
    366 }
    367 
    368 func (d DB) AddPost(content string, threadid, authorid int) (postID int) {
    369 	stmt := `INSERT INTO posts (content, publishtime, threadid, authorid) VALUES (?, ?, ?, ?) RETURNING id`
    370 	publish := time.Now()
    371 	err := d.db.QueryRow(stmt, content, publish, threadid, authorid).Scan(&postID)
    372 	util.Check(err, "add post to thread %d (author %d)", threadid, authorid)
    373 	return
    374 }
    375 
    376 func (d DB) EditPost(content string, postid int) {
    377 	stmt := `UPDATE posts set content = ?, lastedit = ? WHERE id = ?`
    378 	edit := time.Now()
    379 	_, err := d.Exec(stmt, content, edit, postid)
    380 	util.Check(err, "edit post %d", postid)
    381 }
    382 
    383 func (d DB) DeletePost(postid int) error {
    384 	stmt := `DELETE FROM posts WHERE id = ?`
    385 	_, err := d.Exec(stmt, postid)
    386 	return util.Eout(err, "deleting post %d", postid)
    387 }
    388 
    389 func (d DB) CreateTopic(title, description string) {
    390 	stmt := `INSERT INTO topics (name, description) VALUES (?, ?)`
    391 	_, err := d.Exec(stmt, title, description)
    392 	util.Check(err, "creating topic %s", title)
    393 }
    394 
    395 func (d DB) UpdateTopicName(topicid int, newname string) {
    396 	stmt := `UPDATE topics SET name = ? WHERE id = ?`
    397 	_, err := d.Exec(stmt, newname, topicid)
    398 	util.Check(err, "changing topic %d's name to %s", topicid, newname)
    399 }
    400 
    401 func (d DB) UpdateTopicDescription(topicid int, newdesc string) {
    402 	stmt := `UPDATE topics SET description = ? WHERE id = ?`
    403 	_, err := d.Exec(stmt, newdesc, topicid)
    404 	util.Check(err, "changing topic %d's description to %s", topicid, newdesc)
    405 }
    406 
    407 func (d DB) DeleteTopic(topicid int) {
    408 	stmt := `DELETE FROM topics WHERE id = ?`
    409 	_, err := d.Exec(stmt, topicid)
    410 	util.Check(err, "deleting topic %d", topicid)
    411 }
    412 
    413 func (d DB) CreateUser(name, hash string) (int, error) {
    414 	stmt := `INSERT INTO users (name, passwordhash) VALUES (?, ?) RETURNING id`
    415 	var userid int
    416 	err := d.db.QueryRow(stmt, name, hash).Scan(&userid)
    417 	if err != nil {
    418 		return -1, util.Eout(err, "creating user %s", name)
    419 	}
    420 	return userid, nil
    421 }
    422 
    423 func (d DB) GetUserID(name string) (int, error) {
    424 	stmt := `SELECT id FROM users where name = ?`
    425 	var userid int
    426 	err := d.db.QueryRow(stmt, name).Scan(&userid)
    427 	if err != nil {
    428 		return -1, util.Eout(err, "get user id")
    429 	}
    430 	return userid, nil
    431 }
    432 
    433 func (d DB) GetUsername(uid int) (string, error) {
    434 	stmt := `SELECT name FROM users where id = ?`
    435 	var username string
    436 	err := d.db.QueryRow(stmt, uid).Scan(&username)
    437 	if err != nil {
    438 		return "", util.Eout(err, "get username")
    439 	}
    440 	return username, nil
    441 }
    442 
    443 func (d DB) GetPasswordHash(username string) (string, int, error) {
    444 	stmt := `SELECT passwordhash, id FROM users where name = ?`
    445 	var hash string
    446 	var userid int
    447 	err := d.db.QueryRow(stmt, username).Scan(&hash, &userid)
    448 	if err != nil {
    449 		return "", -1, util.Eout(err, "get password hash")
    450 	}
    451 	return hash, userid, nil
    452 }
    453 
    454 func (d DB) existsQuery(substmt string, args ...interface{}) (bool, error) {
    455 	stmt := fmt.Sprintf(`SELECT exists (%s)`, substmt)
    456 	var exists bool
    457 	err := d.db.QueryRow(stmt, args...).Scan(&exists)
    458 	if err != nil {
    459 		return false, util.Eout(err, "exists: %s", substmt)
    460 	}
    461 	return exists, nil
    462 }
    463 
    464 func (d DB) CheckUserExists(userid int) (bool, error) {
    465 	stmt := `SELECT 1 FROM users WHERE id = ?`
    466 	return d.existsQuery(stmt, userid)
    467 }
    468 
    469 func (d DB) CheckUsernameExists(username string) (bool, error) {
    470 	stmt := `SELECT 1 FROM users WHERE name = ?`
    471 	return d.existsQuery(stmt, username)
    472 }
    473 
    474 func (d DB) CheckThreadExists(threadid int) (bool, error) {
    475 	stmt := `SELECT 1 FROM threads WHERE id = ?`
    476 	return d.existsQuery(stmt, threadid)
    477 }
    478 
    479 func (d DB) UpdateUserName(userid int, newname string) {
    480 	stmt := `UPDATE users SET name = ? WHERE id = ?`
    481 	_, err := d.Exec(stmt, newname, userid)
    482 	util.Check(err, "changing user %d's name to %s", userid, newname)
    483 }
    484 
    485 func (d DB) UpdateUserPasswordHash(userid int, newhash string) {
    486 	stmt := `UPDATE users SET passwordhash = ? WHERE id = ?`
    487 	_, err := d.Exec(stmt, newhash, userid)
    488 	util.Check(err, "changing user %d's description to %s", userid, newhash)
    489 }
    490 
    491 func (d DB) GetSystemUserid() int {
    492 	ed := util.Describe("get system user id")
    493 	systemUserid, err := d.GetUserID(SYSTEM_USER_NAME)
    494 	// it should always exist
    495 	if err != nil {
    496 		log.Fatalln(ed.Eout(err, "get system user id"))
    497 	}
    498 	return systemUserid
    499 }
    500 
    501 func (d DB) AddRegistration(userid int, verificationLink string) error {
    502 	ed := util.Describe("add registration")
    503 	stmt := `INSERT INTO registrations (userid, host, link, time) VALUES (?, ?, ?, ?)`
    504 	t := time.Now()
    505 	u, err := url.Parse(verificationLink)
    506 	if err = ed.Eout(err, "parse url"); err != nil {
    507 		return err
    508 	}
    509 	_, err = d.Exec(stmt, userid, u.Host, verificationLink, t)
    510 	if err = ed.Eout(err, "add registration"); err != nil {
    511 		return err
    512 	}
    513 	return nil
    514 }
    515 
    516 /* for moderation operations and queries, see database/moderation.go */
    517 
    518 func (d DB) GetUsers(includeAdmin bool) []User {
    519 	ed := util.Describe("get users")
    520 	query := `SELECT u.name, u.id
    521   FROM users u 
    522 	%s
    523   ORDER BY u.name
    524   `
    525 
    526 	if includeAdmin {
    527 		query = fmt.Sprintf(query, "") // do nothing
    528 	} else {
    529 		query = fmt.Sprintf(query, "WHERE u.id NOT IN (select id from admins)") // do nothing
    530 	}
    531 
    532 	stmt, err := d.db.Prepare(query)
    533 	ed.Check(err, "prep stmt")
    534 	defer stmt.Close()
    535 
    536 	rows, err := stmt.Query()
    537 	util.Check(err, "run query")
    538 	defer rows.Close()
    539 
    540 	var user User
    541 	var users []User
    542 	for rows.Next() {
    543 		if err := rows.Scan(&user.Name, &user.ID); err != nil {
    544 			ed.Check(err, "scanning loop")
    545 		}
    546 		users = append(users, user)
    547 	}
    548 	return users
    549 }
    550 
    551 func (d DB) ResetPassword(userid int) (string, error) {
    552 	ed := util.Describe("reset password")
    553 	exists, err := d.CheckUserExists(userid)
    554 	if !exists {
    555 		return "", errors.New(fmt.Sprintf("reset password: userid %d did not exist", userid))
    556 	} else if err != nil {
    557 		return "", fmt.Errorf("reset password encountered an error (%w)", err)
    558 	}
    559 	// generate new password for user and set it in the database
    560 	newPassword := crypto.GeneratePassword()
    561 	passwordHash, err := crypto.HashPassword(newPassword)
    562 	if err != nil {
    563 		return "", ed.Eout(err, "hash password")
    564 	}
    565 	d.UpdateUserPasswordHash(userid, passwordHash)
    566 	return newPassword, nil
    567 }