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 }