diff --git a/go.mod b/go.mod index baf635988..4ed32b16c 100644 --- a/go.mod +++ b/go.mod @@ -17,10 +17,12 @@ require ( github.com/lib/pq v1.10.6 github.com/muesli/go-app-paths v0.2.2 github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pkg/errors v0.9.1 github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 github.com/sirupsen/logrus v1.9.0 github.com/spf13/cobra v1.5.0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa + golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 modernc.org/sqlite v1.18.1 ) @@ -45,7 +47,6 @@ require ( go.uber.org/atomic v1.9.0 // indirect golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect - golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 // indirect golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/tools v0.1.10 // indirect diff --git a/internal/cmd/add.go b/internal/cmd/add.go index 18d4b4d36..2fbd9a868 100644 --- a/internal/cmd/add.go +++ b/internal/cmd/add.go @@ -58,7 +58,7 @@ func addHandler(cmd *cobra.Command, args []string) { // Create bookmark ID var err error - book.ID, err = db.CreateNewID("bookmark") + book.ID, err = db.CreateNewID(cmd.Context(), "bookmark") if err != nil { cError.Printf("Failed to create ID: %v\n", err) os.Exit(1) @@ -111,7 +111,7 @@ func addHandler(cmd *cobra.Command, args []string) { } // Save bookmark to database - _, err = db.SaveBookmarks(book) + _, err = db.SaveBookmarks(cmd.Context(), book) if err != nil { cError.Printf("Failed to save bookmark: %v\n", err) os.Exit(1) diff --git a/internal/cmd/check.go b/internal/cmd/check.go index 7a99f846c..8d0ae4a97 100644 --- a/internal/cmd/check.go +++ b/internal/cmd/check.go @@ -53,7 +53,7 @@ func checkHandler(cmd *cobra.Command, args []string) { // Fetch bookmarks from database filterOptions := database.GetBookmarksOptions{IDs: ids} - bookmarks, err := db.GetBookmarks(filterOptions) + bookmarks, err := db.GetBookmarks(cmd.Context(), filterOptions) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) os.Exit(1) @@ -88,7 +88,7 @@ func checkHandler(cmd *cobra.Command, args []string) { _, err := httpClient.Get(book.URL) if err != nil { chProblem <- book.ID - chMessage <- fmt.Errorf("Failed to reach %s: %v", book.URL, err) + chMessage <- fmt.Errorf("failed to reach %s: %v", book.URL, err) return } diff --git a/internal/cmd/delete.go b/internal/cmd/delete.go index 93a6b52a4..fce67a633 100644 --- a/internal/cmd/delete.go +++ b/internal/cmd/delete.go @@ -52,7 +52,7 @@ func deleteHandler(cmd *cobra.Command, args []string) { } // Delete bookmarks from database - err = db.DeleteBookmarks(ids...) + err = db.DeleteBookmarks(cmd.Context(), ids...) if err != nil { cError.Printf("Failed to delete bookmarks: %v\n", err) os.Exit(1) diff --git a/internal/cmd/export.go b/internal/cmd/export.go index 74eaaabf8..27f75ee43 100644 --- a/internal/cmd/export.go +++ b/internal/cmd/export.go @@ -24,7 +24,7 @@ func exportCmd() *cobra.Command { func exportHandler(cmd *cobra.Command, args []string) { // Fetch bookmarks from database - bookmarks, err := db.GetBookmarks(database.GetBookmarksOptions{}) + bookmarks, err := db.GetBookmarks(cmd.Context(), database.GetBookmarksOptions{}) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) os.Exit(1) diff --git a/internal/cmd/import.go b/internal/cmd/import.go index d917b9ee7..2f46aa9ae 100644 --- a/internal/cmd/import.go +++ b/internal/cmd/import.go @@ -38,7 +38,7 @@ func importHandler(cmd *cobra.Command, args []string) { } // Prepare bookmark's ID - bookID, err := db.CreateNewID("bookmark") + bookID, err := db.CreateNewID(cmd.Context(), "bookmark") if err != nil { cError.Printf("Failed to create ID: %v\n", err) os.Exit(1) @@ -91,7 +91,13 @@ func importHandler(cmd *cobra.Command, args []string) { return } - if _, exist := db.GetBookmark(0, url); exist { + _, exist, err := db.GetBookmark(cmd.Context(), 0, url) + if err != nil { + cError.Printf("Skip %s: Get Bookmark fail, %v", url, err) + return + } + + if exist { cError.Printf("Skip %s: URL already exists\n", url) mapURL[url] = struct{}{} return @@ -127,7 +133,7 @@ func importHandler(cmd *cobra.Command, args []string) { }) // Save bookmark to database - bookmarks, err = db.SaveBookmarks(bookmarks...) + bookmarks, err = db.SaveBookmarks(cmd.Context(), bookmarks...) if err != nil { cError.Printf("Failed to save bookmarks: %v\n", err) os.Exit(1) diff --git a/internal/cmd/open.go b/internal/cmd/open.go index ce545e1a8..12ab56763 100644 --- a/internal/cmd/open.go +++ b/internal/cmd/open.go @@ -73,7 +73,7 @@ func openHandler(cmd *cobra.Command, args []string) { WithContent: true, } - bookmarks, err := db.GetBookmarks(getOptions) + bookmarks, err := db.GetBookmarks(cmd.Context(), getOptions) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) os.Exit(1) diff --git a/internal/cmd/pocket.go b/internal/cmd/pocket.go index f7d2ab553..0cfa8dd7b 100644 --- a/internal/cmd/pocket.go +++ b/internal/cmd/pocket.go @@ -26,7 +26,7 @@ func pocketCmd() *cobra.Command { func pocketHandler(cmd *cobra.Command, args []string) { // Prepare bookmark's ID - bookID, err := db.CreateNewID("bookmark") + bookID, err := db.CreateNewID(cmd.Context(), "bookmark") if err != nil { cError.Printf("Failed to create ID: %v\n", err) return @@ -77,7 +77,13 @@ func pocketHandler(cmd *cobra.Command, args []string) { return } - if _, exist := db.GetBookmark(0, url); exist { + _, exist, err := db.GetBookmark(cmd.Context(), 0, url) + if err != nil { + cError.Printf("Skip %s: Get Bookmark fail, %v", url, err) + return + } + + if exist { cError.Printf("Skip %s: URL already exists\n", url) mapURL[url] = struct{}{} return @@ -106,7 +112,7 @@ func pocketHandler(cmd *cobra.Command, args []string) { }) // Save bookmark to database - bookmarks, err = db.SaveBookmarks(bookmarks...) + bookmarks, err = db.SaveBookmarks(cmd.Context(), bookmarks...) if err != nil { cError.Printf("Failed to save bookmarks: %v\n", err) os.Exit(1) diff --git a/internal/cmd/print.go b/internal/cmd/print.go index 03eb6896b..bd802d580 100644 --- a/internal/cmd/print.go +++ b/internal/cmd/print.go @@ -61,7 +61,7 @@ func printHandler(cmd *cobra.Command, args []string) { OrderMethod: orderMethod, } - bookmarks, err := db.GetBookmarks(searchOptions) + bookmarks, err := db.GetBookmarks(cmd.Context(), searchOptions) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) return diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ab51c972e..278437920 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -8,6 +8,7 @@ import ( "github.com/go-shiori/shiori/internal/database" apppaths "github.com/muesli/go-app-paths" "github.com/spf13/cobra" + "golang.org/x/net/context" ) var ( @@ -61,7 +62,7 @@ func preRunRootHandler(cmd *cobra.Command, args []string) { } // Open database - db, err = openDatabase() + db, err = openDatabase(cmd.Context()) if err != nil { cError.Printf("Failed to open database: %v\n", err) os.Exit(1) @@ -101,33 +102,33 @@ func getDataDir(portableMode bool) (string, error) { return ".", nil } -func openDatabase() (database.DB, error) { +func openDatabase(ctx context.Context) (database.DB, error) { switch dbms, _ := os.LookupEnv("SHIORI_DBMS"); dbms { case "mysql": - return openMySQLDatabase() + return openMySQLDatabase(ctx) case "postgresql": - return openPostgreSQLDatabase() + return openPostgreSQLDatabase(ctx) default: - return openSQLiteDatabase() + return openSQLiteDatabase(ctx) } } -func openSQLiteDatabase() (database.DB, error) { +func openSQLiteDatabase(ctx context.Context) (database.DB, error) { dbPath := fp.Join(dataDir, "shiori.db") - return database.OpenSQLiteDatabase(dbPath) + return database.OpenSQLiteDatabase(ctx, dbPath) } -func openMySQLDatabase() (database.DB, error) { +func openMySQLDatabase(ctx context.Context) (database.DB, error) { user, _ := os.LookupEnv("SHIORI_MYSQL_USER") password, _ := os.LookupEnv("SHIORI_MYSQL_PASS") dbName, _ := os.LookupEnv("SHIORI_MYSQL_NAME") dbAddress, _ := os.LookupEnv("SHIORI_MYSQL_ADDRESS") connString := fmt.Sprintf("%s:%s@%s/%s?charset=utf8mb4", user, password, dbAddress, dbName) - return database.OpenMySQLDatabase(connString) + return database.OpenMySQLDatabase(ctx, connString) } -func openPostgreSQLDatabase() (database.DB, error) { +func openPostgreSQLDatabase(ctx context.Context) (database.DB, error) { host, _ := os.LookupEnv("SHIORI_PG_HOST") port, _ := os.LookupEnv("SHIORI_PG_PORT") user, _ := os.LookupEnv("SHIORI_PG_USER") @@ -136,5 +137,5 @@ func openPostgreSQLDatabase() (database.DB, error) { connString := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", host, port, user, password, dbName) - return database.OpenPGDatabase(connString) + return database.OpenPGDatabase(ctx, connString) } diff --git a/internal/cmd/update.go b/internal/cmd/update.go index 80d03c7db..d289fd3a1 100644 --- a/internal/cmd/update.go +++ b/internal/cmd/update.go @@ -94,7 +94,7 @@ func updateHandler(cmd *cobra.Command, args []string) { IDs: ids, } - bookmarks, err := db.GetBookmarks(filterOptions) + bookmarks, err := db.GetBookmarks(cmd.Context(), filterOptions) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) os.Exit(1) @@ -159,7 +159,7 @@ func updateHandler(cmd *cobra.Command, args []string) { content, contentType, err := core.DownloadBookmark(book.URL) if err != nil { chProblem <- book.ID - chMessage <- fmt.Errorf("Failed to download %s: %v", book.URL, err) + chMessage <- fmt.Errorf("failed to download %s: %v", book.URL, err) return } @@ -178,7 +178,7 @@ func updateHandler(cmd *cobra.Command, args []string) { if err != nil { chProblem <- book.ID - chMessage <- fmt.Errorf("Failed to process %s: %v", book.URL, err) + chMessage <- fmt.Errorf("failed to process %s: %v", book.URL, err) return } @@ -285,7 +285,7 @@ func updateHandler(cmd *cobra.Command, args []string) { } // Save bookmarks to database - bookmarks, err = db.SaveBookmarks(bookmarks...) + bookmarks, err = db.SaveBookmarks(cmd.Context(), bookmarks...) if err != nil { cError.Printf("Failed to save bookmark: %v\n", err) os.Exit(1) diff --git a/internal/cmd/utils.go b/internal/cmd/utils.go index bd7788c39..ec94732f0 100644 --- a/internal/cmd/utils.go +++ b/internal/cmd/utils.go @@ -27,7 +27,7 @@ var ( cInfo = color.New(color.FgHiCyan) cError = color.New(color.FgHiRed) - errInvalidIndex = errors.New("Index is not valid") + errInvalidIndex = errors.New("index is not valid") ) func normalizeSpace(str string) string { diff --git a/internal/database/database.go b/internal/database/database.go index cb829e420..58cf0dbaa 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1,10 +1,13 @@ package database import ( - "database/sql" + "context" "embed" + "log" "github.com/go-shiori/shiori/internal/model" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" ) //go:embed migrations/* @@ -46,44 +49,65 @@ type DB interface { Migrate() error // SaveBookmarks saves bookmarks data to database. - SaveBookmarks(bookmarks ...model.Bookmark) ([]model.Bookmark, error) + SaveBookmarks(ctx context.Context, bookmarks ...model.Bookmark) ([]model.Bookmark, error) // GetBookmarks fetch list of bookmarks based on submitted options. - GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmark, error) + GetBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]model.Bookmark, error) // GetBookmarksCount get count of bookmarks in database. - GetBookmarksCount(opts GetBookmarksOptions) (int, error) + GetBookmarksCount(ctx context.Context, opts GetBookmarksOptions) (int, error) // DeleteBookmarks removes all record with matching ids from database. - DeleteBookmarks(ids ...int) error + DeleteBookmarks(ctx context.Context, ids ...int) error - // GetBookmark fetches bookmark based on its ID or URL. - GetBookmark(id int, url string) (model.Bookmark, bool) + // GetBookmark fetchs bookmark based on its ID or URL. + GetBookmark(ctx context.Context, id int, url string) (model.Bookmark, bool, error) // SaveAccount saves new account in database - SaveAccount(model.Account) error + SaveAccount(ctx context.Context, a model.Account) error // GetAccounts fetch list of account (without its password) with matching keyword. - GetAccounts(opts GetAccountsOptions) ([]model.Account, error) + GetAccounts(ctx context.Context, opts GetAccountsOptions) ([]model.Account, error) // GetAccount fetch account with matching username. - GetAccount(username string) (model.Account, bool) + GetAccount(ctx context.Context, username string) (model.Account, bool, error) // DeleteAccounts removes all record with matching usernames - DeleteAccounts(usernames ...string) error + DeleteAccounts(ctx context.Context, usernames ...string) error // GetTags fetch list of tags and its frequency from database. - GetTags() ([]model.Tag, error) + GetTags(ctx context.Context) ([]model.Tag, error) // RenameTag change the name of a tag. - RenameTag(id int, newName string) error + RenameTag(ctx context.Context, id int, newName string) error // CreateNewID creates new id for specified table. - CreateNewID(table string) (int, error) + CreateNewID(ctx context.Context, table string) (int, error) } -func checkError(err error) { - if err != nil && err != sql.ErrNoRows { - panic(err) +type dbbase struct { + sqlx.DB +} + +func (db *dbbase) withTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error { + tx, err := db.BeginTxx(ctx, nil) + if err != nil { + return errors.WithStack(err) } + + defer func() { + if err := tx.Commit(); err != nil { + log.Printf("error during commit: %s", err) + } + }() + + err = fn(tx) + if err != nil { + if err := tx.Rollback(); err != nil { + log.Printf("error during rollback: %s", err) + } + return errors.WithStack(err) + } + + return err } diff --git a/internal/database/mysql.go b/internal/database/mysql.go index 4ce6b8368..7ce285e4e 100644 --- a/internal/database/mysql.go +++ b/internal/database/mysql.go @@ -1,9 +1,9 @@ package database import ( + "context" "database/sql" "fmt" - "log" "strings" "time" @@ -12,33 +12,42 @@ import ( "github.com/golang-migrate/migrate/v4/database/mysql" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jmoiron/sqlx" + "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" ) // MySQLDatabase is implementation of Database interface // for connecting to MySQL or MariaDB database. type MySQLDatabase struct { - sqlx.DB + dbbase } // OpenMySQLDatabase creates and opens connection to a MySQL Database. -func OpenMySQLDatabase(connString string) (mysqlDB *MySQLDatabase, err error) { +func OpenMySQLDatabase(ctx context.Context, connString string) (mysqlDB *MySQLDatabase, err error) { // Open database and start transaction - db := sqlx.MustConnect("mysql", connString) + db, err := sqlx.ConnectContext(ctx, "mysql", connString) + if err != nil { + return nil, errors.WithStack(err) + } + db.SetMaxOpenConns(100) db.SetConnMaxLifetime(time.Second) // in case mysql client has longer timeout (driver issue #674) - mysqlDB = &MySQLDatabase{*db} + mysqlDB = &MySQLDatabase{dbbase: dbbase{*db}} return mysqlDB, err } // Migrate runs migrations for this database engine func (db *MySQLDatabase) Migrate() error { sourceDriver, err := iofs.New(migrations, "migrations/mysql") - checkError(err) + if err != nil { + return errors.WithStack(err) + } dbDriver, err := mysql.WithInstance(db.DB.DB, &mysql.Config{}) - checkError(err) + if err != nil { + return errors.WithStack(err) + } migration, err := migrate.NewWithInstance( "iofs", @@ -46,138 +55,149 @@ func (db *MySQLDatabase) Migrate() error { "mysql", dbDriver, ) - - checkError(err) + if err != nil { + return errors.WithStack(err) + } return migration.Up() } // SaveBookmarks saves new or updated bookmarks to database. // Returns the saved ID and error message if any happened. -func (db *MySQLDatabase) SaveBookmarks(bookmarks ...model.Bookmark) (result []model.Bookmark, err error) { - // Prepare transaction - tx, err := db.Beginx() - if err != nil { - return []model.Bookmark{}, err - } - - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) - } +func (db *MySQLDatabase) SaveBookmarks(ctx context.Context, bookmarks ...model.Bookmark) ([]model.Bookmark, error) { + var result []model.Bookmark + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Prepare statement + stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark + (id, url, title, excerpt, author, public, content, html, modified) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + url = VALUES(url), + title = VALUES(title), + excerpt = VALUES(excerpt), + author = VALUES(author), + public = VALUES(public), + content = VALUES(content), + html = VALUES(html), + modified = VALUES(modified)`) + if err != nil { + return errors.WithStack(err) + } - result = []model.Bookmark{} - err = panicErr + stmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = ?`) + if err != nil { + return errors.WithStack(err) } - }() - // Prepare statement - stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark - (id, url, title, excerpt, author, public, content, html, modified) - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - url = VALUES(url), - title = VALUES(title), - excerpt = VALUES(excerpt), - author = VALUES(author), - public = VALUES(public), - content = VALUES(content), - html = VALUES(html), - modified = VALUES(modified)`) - checkError(err) - - stmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = ?`) - checkError(err) - - stmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES (?)`) - checkError(err) - - stmtInsertBookTag, err := tx.Preparex(`INSERT IGNORE INTO bookmark_tag - (tag_id, bookmark_id) VALUES (?, ?)`) - checkError(err) - - stmtDeleteBookTag, err := tx.Preparex(`DELETE FROM bookmark_tag - WHERE bookmark_id = ? AND tag_id = ?`) - checkError(err) - - // Prepare modified time - modifiedTime := time.Now().UTC().Format("2006-01-02 15:04:05") - - // Execute statements - result = []model.Bookmark{} - for _, book := range bookmarks { - // Check ID, URL and title - if book.ID == 0 { - panic(fmt.Errorf("ID must not be empty")) + stmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES (?)`) + if err != nil { + return errors.WithStack(err) } - if book.URL == "" { - panic(fmt.Errorf("URL must not be empty")) + stmtInsertBookTag, err := tx.Preparex(`INSERT IGNORE INTO bookmark_tag + (tag_id, bookmark_id) VALUES (?, ?)`) + if err != nil { + return errors.WithStack(err) } - if book.Title == "" { - panic(fmt.Errorf("title must not be empty")) + stmtDeleteBookTag, err := tx.Preparex(`DELETE FROM bookmark_tag + WHERE bookmark_id = ? AND tag_id = ?`) + if err != nil { + return errors.WithStack(err) } - // Set modified time - book.Modified = modifiedTime - - // Save bookmark - stmtInsertBook.MustExec(book.ID, - book.URL, book.Title, book.Excerpt, book.Author, - book.Public, book.Content, book.HTML, book.Modified) - - // Save book tags - newTags := []model.Tag{} - for _, tag := range book.Tags { - // If it's deleted tag, delete and continue - if tag.Deleted { - stmtDeleteBookTag.MustExec(book.ID, tag.ID) - continue + // Prepare modified time + modifiedTime := time.Now().UTC().Format("2006-01-02 15:04:05") + + // Execute statements + + for _, book := range bookmarks { + // Check ID, URL and title + if book.ID == 0 { + return errors.New("ID must not be empty") } - // Normalize tag name - tagName := strings.ToLower(tag.Name) - tagName = strings.Join(strings.Fields(tagName), " ") + if book.URL == "" { + return errors.New("URL must not be empty") + } - // If tag doesn't have any ID, fetch it from database - if tag.ID == 0 { - err = stmtGetTag.Get(&tag.ID, tagName) - checkError(err) + if book.Title == "" { + return errors.New("title must not be empty") + } - // If tag doesn't exist in database, save it - if tag.ID == 0 { - res := stmtInsertTag.MustExec(tagName) - tagID64, err := res.LastInsertId() - checkError(err) + // Set modified time + book.Modified = modifiedTime + + // Save bookmark + _, err := stmtInsertBook.ExecContext(ctx, book.ID, + book.URL, book.Title, book.Excerpt, book.Author, + book.Public, book.Content, book.HTML, book.Modified) + if err != nil { + return errors.WithStack(err) + } - tag.ID = int(tagID64) + // Save book tags + newTags := []model.Tag{} + for _, tag := range book.Tags { + // If it's deleted tag, delete and continue + if tag.Deleted { + _, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, tag.ID) + if err != nil { + return errors.WithStack(err) + } + + continue } - if _, err := stmtInsertBookTag.Exec(tag.ID, book.ID); err != nil { - log.Printf("error during insert: %s", err) + // Normalize tag name + tagName := strings.ToLower(tag.Name) + tagName = strings.Join(strings.Fields(tagName), " ") + + // If tag doesn't have any ID, fetch it from database + if tag.ID == 0 { + err = stmtGetTag.Get(&tag.ID, tagName) + if err != nil { + return errors.WithStack(err) + } + + // If tag doesn't exist in database, save it + if tag.ID == 0 { + res, err := stmtInsertTag.ExecContext(ctx, tagName) + if err != nil { + return errors.WithStack(err) + } + + tagID64, err := res.LastInsertId() + if err != nil { + return errors.WithStack(err) + } + + tag.ID = int(tagID64) + } + + if _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil { + return errors.WithStack(err) + } } + + newTags = append(newTags, tag) } - newTags = append(newTags, tag) + book.Tags = newTags + result = append(result, book) } - book.Tags = newTags - result = append(result, book) + return nil + }); err != nil { + return result, errors.WithStack(err) } - // Commit transaction - err = tx.Commit() - checkError(err) - - return result, err + return result, nil } // GetBookmarks fetch list of bookmarks based on submitted options. -func (db *MySQLDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmark, error) { +func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]model.Bookmark, error) { // Create initial query columns := []string{ `id`, @@ -285,32 +305,32 @@ func (db *MySQLDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmar // Expand query, because some of the args might be an array query, args, err := sqlx.In(query, args...) if err != nil { - return nil, fmt.Errorf("failed to expand query: %v", err) + return nil, errors.WithStack(err) } // Fetch bookmarks bookmarks := []model.Bookmark{} err = db.Select(&bookmarks, query, args...) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch data: %v", err) + return nil, errors.WithStack(err) } // Fetch tags for each bookmarks - stmtGetTags, err := db.Preparex(`SELECT t.id, t.name + stmtGetTags, err := db.PreparexContext(ctx, `SELECT t.id, t.name FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE bt.bookmark_id = ? ORDER BY t.name`) if err != nil { - return nil, fmt.Errorf("failed to prepare tag query: %v", err) + return nil, errors.WithStack(err) } defer stmtGetTags.Close() for i, book := range bookmarks { book.Tags = []model.Tag{} - err = stmtGetTags.Select(&book.Tags, book.ID) + err = stmtGetTags.SelectContext(ctx, &book.Tags, book.ID) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch tags: %v", err) + return nil, errors.WithStack(err) } bookmarks[i] = book @@ -320,7 +340,7 @@ func (db *MySQLDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmar } // GetBookmarksCount fetch count of bookmarks based on submitted options. -func (db *MySQLDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, error) { +func (db *MySQLDatabase) GetBookmarksCount(ctx context.Context, opts GetBookmarksOptions) (int, error) { // Create initial query query := `SELECT COUNT(id) FROM bookmark WHERE 1` @@ -400,70 +420,68 @@ func (db *MySQLDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, error // Expand query, because some of the args might be an array query, args, err := sqlx.In(query, args...) if err != nil { - return 0, fmt.Errorf("failed to expand query: %v", err) + return 0, errors.WithStack(err) } // Fetch count var nBookmarks int - err = db.Get(&nBookmarks, query, args...) + err = db.GetContext(ctx, &nBookmarks, query, args...) if err != nil && err != sql.ErrNoRows { - return 0, fmt.Errorf("failed to fetch count: %v", err) + return 0, errors.WithStack(err) } return nBookmarks, nil } // DeleteBookmarks removes all record with matching ids from database. -func (db *MySQLDatabase) DeleteBookmarks(ids ...int) (err error) { - // Begin transaction - tx, err := db.Beginx() - if err != nil { - return err - } +func (db *MySQLDatabase) DeleteBookmarks(ctx context.Context, ids ...int) (err error) { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Prepare queries + delBookmark := `DELETE FROM bookmark` + delBookmarkTag := `DELETE FROM bookmark_tag` + + // Delete bookmark(s) + if len(ids) == 0 { + _, err := tx.ExecContext(ctx, delBookmarkTag) + if err != nil { + return errors.WithStack(err) + } - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) + _, err = tx.ExecContext(ctx, delBookmark) + if err != nil { + return errors.WithStack(err) } + } else { + delBookmark += ` WHERE id = ?` + delBookmarkTag += ` WHERE bookmark_id = ?` - err = panicErr - } - }() - - // Prepare queries - delBookmark := `DELETE FROM bookmark` - delBookmarkTag := `DELETE FROM bookmark_tag` - - // Delete bookmark(s) - if len(ids) == 0 { - tx.MustExec(delBookmarkTag) - tx.MustExec(delBookmark) - } else { - delBookmark += ` WHERE id = ?` - delBookmarkTag += ` WHERE bookmark_id = ?` - - stmtDelBookmark, _ := tx.Preparex(delBookmark) - stmtDelBookmarkTag, _ := tx.Preparex(delBookmarkTag) - - for _, id := range ids { - stmtDelBookmarkTag.MustExec(id) - stmtDelBookmark.MustExec(id) + stmtDelBookmark, _ := tx.Preparex(delBookmark) + stmtDelBookmarkTag, _ := tx.Preparex(delBookmarkTag) + + for _, id := range ids { + _, err := stmtDelBookmarkTag.ExecContext(ctx, id) + if err != nil { + return errors.WithStack(err) + } + + _, err = stmtDelBookmark.ExecContext(ctx, id) + if err != nil { + return errors.WithStack(err) + } + } } - } - // Commit transaction - err = tx.Commit() - checkError(err) + return nil + }); err != nil { + return errors.WithStack(err) + } - return err + return nil } // GetBookmark fetchs bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. -func (db *MySQLDatabase) GetBookmark(id int, url string) (model.Bookmark, bool) { +func (db *MySQLDatabase) GetBookmark(ctx context.Context, id int, url string) (model.Bookmark, bool, error) { args := []interface{}{id} query := `SELECT id, url, title, excerpt, author, public, @@ -476,34 +494,34 @@ func (db *MySQLDatabase) GetBookmark(id int, url string) (model.Bookmark, bool) } book := model.Bookmark{} - if err := db.Get(&book, query, args...); err != nil { - log.Printf("error during db.get: %s", err) + if err := db.GetContext(ctx, &book, query, args...); err != nil { + return book, false, errors.WithStack(err) } - return book, book.ID != 0 + return book, book.ID != 0, nil } // SaveAccount saves new account to database. Returns error if any happened. -func (db *MySQLDatabase) SaveAccount(account model.Account) (err error) { +func (db *MySQLDatabase) SaveAccount(ctx context.Context, account model.Account) (err error) { // Hash password with bcrypt hashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), 10) if err != nil { - return err + return errors.WithStack(err) } // Insert account to database - _, err = db.Exec(`INSERT INTO account + _, err = db.ExecContext(ctx, `INSERT INTO account (username, password, owner) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE password = VALUES(password), owner = VALUES(owner)`, account.Username, hashedPassword, account.Owner) - return err + return errors.WithStack(err) } // GetAccounts fetch list of account (without its password) based on submitted options. -func (db *MySQLDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, error) { +func (db *MySQLDatabase) GetAccounts(ctx context.Context, opts GetAccountsOptions) ([]model.Account, error) { // Create query args := []interface{}{} query := `SELECT id, username, owner FROM account WHERE 1` @@ -521,9 +539,9 @@ func (db *MySQLDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, // Fetch list account accounts := []model.Account{} - err := db.Select(&accounts, query, args...) + err := db.SelectContext(ctx, &accounts, query, args...) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch accounts: %v", err) + return nil, errors.WithStack(err) } return accounts, nil @@ -531,81 +549,72 @@ func (db *MySQLDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, // GetAccount fetch account with matching username. // Returns the account and boolean whether it's exist or not. -func (db *MySQLDatabase) GetAccount(username string) (model.Account, bool) { +func (db *MySQLDatabase) GetAccount(ctx context.Context, username string) (model.Account, bool, error) { account := model.Account{} - if err := db.Get(&account, `SELECT + if err := db.GetContext(ctx, &account, `SELECT id, username, password, owner FROM account WHERE username = ?`, username, ); err != nil { - log.Printf("error during db.get: %s", err) + return account, false, errors.WithStack(err) } - return account, account.ID != 0 + return account, account.ID != 0, nil } // DeleteAccounts removes all record with matching usernames. -func (db *MySQLDatabase) DeleteAccounts(usernames ...string) (err error) { - // Begin transaction - tx, err := db.Beginx() - if err != nil { - return err - } - - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) +func (db *MySQLDatabase) DeleteAccounts(ctx context.Context, usernames ...string) error { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete account + stmtDelete, _ := tx.Preparex(`DELETE FROM account WHERE username = ?`) + for _, username := range usernames { + _, err := stmtDelete.ExecContext(ctx, username) + if err != nil { + return errors.WithStack(err) } - - err = panicErr } - }() - // Delete account - stmtDelete, _ := tx.Preparex(`DELETE FROM account WHERE username = ?`) - for _, username := range usernames { - stmtDelete.MustExec(username) + return nil + }); err != nil { + return errors.WithStack(err) } - // Commit transaction - err = tx.Commit() - checkError(err) - - return err + return nil } // GetTags fetch list of tags and their frequency. -func (db *MySQLDatabase) GetTags() ([]model.Tag, error) { +func (db *MySQLDatabase) GetTags(ctx context.Context) ([]model.Tag, error) { tags := []model.Tag{} query := `SELECT bt.tag_id id, t.name, COUNT(bt.tag_id) n_bookmarks FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id GROUP BY bt.tag_id ORDER BY t.name` - err := db.Select(&tags, query) + err := db.SelectContext(ctx, &tags, query) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch tags: %v", err) + return nil, errors.WithStack(err) } return tags, nil } // RenameTag change the name of a tag. -func (db *MySQLDatabase) RenameTag(id int, newName string) error { - _, err := db.Exec(`UPDATE tag SET name = ? WHERE id = ?`, newName, id) - return err +func (db *MySQLDatabase) RenameTag(ctx context.Context, id int, newName string) error { + err := db.withTx(ctx, func(tx *sqlx.Tx) error { + _, err := db.ExecContext(ctx, `UPDATE tag SET name = ? WHERE id = ?`, newName, id) + return errors.WithStack(err) + }) + + return errors.WithStack(err) } // CreateNewID creates new ID for specified table -func (db *MySQLDatabase) CreateNewID(table string) (int, error) { +func (db *MySQLDatabase) CreateNewID(ctx context.Context, table string) (int, error) { var tableID int query := fmt.Sprintf(`SELECT IFNULL(MAX(id) + 1, 1) FROM %s`, table) - err := db.Get(&tableID, query) + err := db.GetContext(ctx, &tableID, query) if err != nil && err != sql.ErrNoRows { - return -1, err + return -1, errors.WithStack(err) } return tableID, nil diff --git a/internal/database/pg.go b/internal/database/pg.go index a90d57286..3c3d8e5cc 100644 --- a/internal/database/pg.go +++ b/internal/database/pg.go @@ -1,9 +1,9 @@ package database import ( + "context" "database/sql" "fmt" - "log" "strings" "time" @@ -12,33 +12,42 @@ import ( "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jmoiron/sqlx" + "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" ) // PGDatabase is implementation of Database interface // for connecting to PostgreSQL database. type PGDatabase struct { - sqlx.DB + dbbase } // OpenPGDatabase creates and opens connection to a PostgreSQL Database. -func OpenPGDatabase(connString string) (pgDB *PGDatabase, err error) { +func OpenPGDatabase(ctx context.Context, connString string) (pgDB *PGDatabase, err error) { // Open database and start transaction - db := sqlx.MustConnect("postgres", connString) + db, err := sqlx.ConnectContext(ctx, "postgres", connString) + if err != nil { + return nil, errors.WithStack(err) + } + db.SetMaxOpenConns(100) db.SetConnMaxLifetime(time.Second) - pgDB = &PGDatabase{*db} + pgDB = &PGDatabase{dbbase: dbbase{*db}} return pgDB, err } // Migrate runs migrations for this database engine func (db *PGDatabase) Migrate() error { sourceDriver, err := iofs.New(migrations, "migrations/postgres") - checkError(err) + if err != nil { + return errors.WithStack(err) + } dbDriver, err := postgres.WithInstance(db.DB.DB, &postgres.Config{}) - checkError(err) + if err != nil { + return errors.WithStack(err) + } migration, err := migrate.NewWithInstance( "iofs", @@ -46,138 +55,143 @@ func (db *PGDatabase) Migrate() error { "postgres", dbDriver, ) - - checkError(err) + if err != nil { + return errors.WithStack(err) + } return migration.Up() } // SaveBookmarks saves new or updated bookmarks to database. // Returns the saved ID and error message if any happened. -func (db *PGDatabase) SaveBookmarks(bookmarks ...model.Bookmark) (result []model.Bookmark, err error) { - // Prepare transaction - tx, err := db.Beginx() - if err != nil { - return []model.Bookmark{}, err - } - - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) - } +func (db *PGDatabase) SaveBookmarks(ctx context.Context, bookmarks ...model.Bookmark) (result []model.Bookmark, err error) { + result = []model.Bookmark{} + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Prepare statement + stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark + (url, title, excerpt, author, public, content, html, modified) + VALUES($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT(url) DO UPDATE SET + url = $1, + title = $2, + excerpt = $3, + author = $4, + public = $5, + content = $6, + html = $7, + modified = $8`) + if err != nil { + return errors.WithStack(err) + } - result = []model.Bookmark{} - err = panicErr + stmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = $1`) + if err != nil { + return errors.WithStack(err) } - }() - - // Prepare statement - stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark - (url, title, excerpt, author, public, content, html, modified) - VALUES($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT(url) DO UPDATE SET - url = $1, - title = $2, - excerpt = $3, - author = $4, - public = $5, - content = $6, - html = $7, - modified = $8`) - checkError(err) - - stmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = $1`) - checkError(err) - - stmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES ($1) RETURNING id`) - checkError(err) - - stmtInsertBookTag, err := tx.Preparex(`INSERT INTO bookmark_tag - (tag_id, bookmark_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`) - checkError(err) - - stmtDeleteBookTag, err := tx.Preparex(`DELETE FROM bookmark_tag - WHERE bookmark_id = $1 AND tag_id = $2`) - checkError(err) - - // Prepare modified time - modifiedTime := time.Now().UTC().Format("2006-01-02 15:04:05") - - // Execute statements - result = []model.Bookmark{} - for _, book := range bookmarks { - // Check ID, URL and title - if book.ID == 0 { - panic(fmt.Errorf("ID must not be empty")) + + stmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES ($1) RETURNING id`) + if err != nil { + return errors.WithStack(err) } - if book.URL == "" { - panic(fmt.Errorf("URL must not be empty")) + stmtInsertBookTag, err := tx.Preparex(`INSERT INTO bookmark_tag + (tag_id, bookmark_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`) + if err != nil { + return errors.WithStack(err) } - if book.Title == "" { - panic(fmt.Errorf("title must not be empty")) + stmtDeleteBookTag, err := tx.Preparex(`DELETE FROM bookmark_tag + WHERE bookmark_id = $1 AND tag_id = $2`) + if err != nil { + return errors.WithStack(err) } - // Set modified time - book.Modified = modifiedTime - - // Save bookmark - stmtInsertBook.MustExec( - book.URL, book.Title, book.Excerpt, book.Author, - book.Public, book.Content, book.HTML, book.Modified) - - // Save book tags - newTags := []model.Tag{} - for _, tag := range book.Tags { - // If it's deleted tag, delete and continue - if tag.Deleted { - stmtDeleteBookTag.MustExec(book.ID, tag.ID) - continue + // Prepare modified time + modifiedTime := time.Now().UTC().Format("2006-01-02 15:04:05") + + // Execute statements + result = []model.Bookmark{} + for _, book := range bookmarks { + // Check ID, URL and title + if book.ID == 0 { + return errors.New("ID must not be empty") } - // Normalize tag name - tagName := strings.ToLower(tag.Name) - tagName = strings.Join(strings.Fields(tagName), " ") + if book.URL == "" { + return errors.New("URL must not be empty") + } - // If tag doesn't have any ID, fetch it from database - if tag.ID == 0 { - err = stmtGetTag.Get(&tag.ID, tagName) - checkError(err) + if book.Title == "" { + return errors.New("title must not be empty") + } - // If tag doesn't exist in database, save it - if tag.ID == 0 { - var tagID64 int64 - err = stmtInsertTag.Get(&tagID64, tagName) - checkError(err) + // Set modified time + book.Modified = modifiedTime + + // Save bookmark + _, err := stmtInsertBook.ExecContext(ctx, + book.URL, book.Title, book.Excerpt, book.Author, + book.Public, book.Content, book.HTML, book.Modified) + if err != nil { + return errors.WithStack(err) + } - tag.ID = int(tagID64) + // Save book tags + newTags := []model.Tag{} + for _, tag := range book.Tags { + // If it's deleted tag, delete and continue + if tag.Deleted { + _, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, tag.ID) + if err != nil { + return errors.WithStack(err) + } + continue } - if _, err := stmtInsertBookTag.Exec(tag.ID, book.ID); err != nil { - log.Printf("error during insert: %s", err) + // Normalize tag name + tagName := strings.ToLower(tag.Name) + tagName = strings.Join(strings.Fields(tagName), " ") + + // If tag doesn't have any ID, fetch it from database + if tag.ID == 0 { + err = stmtGetTag.Get(&tag.ID, tagName) + if err != nil { + return errors.WithStack(err) + } + + // If tag doesn't exist in database, save it + if tag.ID == 0 { + var tagID64 int64 + err = stmtInsertTag.Get(&tagID64, tagName) + if err != nil { + return errors.WithStack(err) + } + + tag.ID = int(tagID64) + } + + if _, err := stmtInsertBookTag.Exec(tag.ID, book.ID); err != nil { + return errors.WithStack(err) + } } + + newTags = append(newTags, tag) } - newTags = append(newTags, tag) + book.Tags = newTags + result = append(result, book) } - book.Tags = newTags - result = append(result, book) + return nil + }); err != nil { + return nil, errors.WithStack(err) } - // Commit transaction - err = tx.Commit() - checkError(err) - - return result, err + return result, nil } // GetBookmarks fetch list of bookmarks based on submitted options. -func (db *PGDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmark, error) { +func (db *PGDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]model.Bookmark, error) { // Create initial query columns := []string{ `id`, @@ -296,13 +310,13 @@ func (db *PGDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmark, // Fetch bookmarks bookmarks := []model.Bookmark{} - err = db.Select(&bookmarks, query, args...) + err = db.SelectContext(ctx, &bookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to fetch data: %v", err) } // Fetch tags for each bookmarks - stmtGetTags, err := db.Preparex(`SELECT t.id, t.name + stmtGetTags, err := db.PreparexContext(ctx, `SELECT t.id, t.name FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE bt.bookmark_id = $1 @@ -314,7 +328,7 @@ func (db *PGDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmark, for i, book := range bookmarks { book.Tags = []model.Tag{} - err = stmtGetTags.Select(&book.Tags, book.ID) + err = stmtGetTags.SelectContext(ctx, &book.Tags, book.ID) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to fetch tags: %v", err) } @@ -326,7 +340,7 @@ func (db *PGDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmark, } // GetBookmarksCount fetch count of bookmarks based on submitted options. -func (db *PGDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, error) { +func (db *PGDatabase) GetBookmarksCount(ctx context.Context, opts GetBookmarksOptions) (int, error) { // Create initial query query := `SELECT COUNT(id) FROM bookmark WHERE TRUE` @@ -404,73 +418,82 @@ func (db *PGDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, error) { // Expand query, because some of the args might be an array var err error - query, args, _ := sqlx.Named(query, arg) + query, args, err := sqlx.Named(query, arg) + if err != nil { + return 0, errors.WithStack(err) + } + query, args, err = sqlx.In(query, args...) if err != nil { - return 0, fmt.Errorf("failed to expand query: %v", err) + return 0, errors.WithStack(err) } query = db.Rebind(query) // Fetch count var nBookmarks int - err = db.Get(&nBookmarks, query, args...) + err = db.GetContext(ctx, &nBookmarks, query, args...) if err != nil && err != sql.ErrNoRows { - return 0, fmt.Errorf("failed to fetch count: %v", err) + return 0, errors.WithStack(err) } return nBookmarks, nil } // DeleteBookmarks removes all record with matching ids from database. -func (db *PGDatabase) DeleteBookmarks(ids ...int) (err error) { - // Begin transaction - tx, err := db.Beginx() - if err != nil { - return err - } +func (db *PGDatabase) DeleteBookmarks(ctx context.Context, ids ...int) (err error) { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Prepare queries + delBookmark := `DELETE FROM bookmark` + delBookmarkTag := `DELETE FROM bookmark_tag` + + // Delete bookmark(s) + if len(ids) == 0 { + _, err := tx.ExecContext(ctx, delBookmarkTag) + if err != nil { + return errors.WithStack(err) + } - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) + _, err = tx.ExecContext(ctx, delBookmark) + if err != nil { + return errors.WithStack(err) + } + } else { + delBookmark += ` WHERE id = $1` + delBookmarkTag += ` WHERE bookmark_id = $1` + + stmtDelBookmark, err := tx.Preparex(delBookmark) + if err != nil { + return errors.WithStack(err) + } + stmtDelBookmarkTag, err := tx.Preparex(delBookmarkTag) + if err != nil { + return errors.WithStack(err) + } + + for _, id := range ids { + _, err = stmtDelBookmarkTag.ExecContext(ctx, id) + if err != nil { + return errors.WithStack(err) + } + + _, err = stmtDelBookmark.ExecContext(ctx, id) + if err != nil { + return errors.WithStack(err) + } } - err = panicErr - } - }() - - // Prepare queries - delBookmark := `DELETE FROM bookmark` - delBookmarkTag := `DELETE FROM bookmark_tag` - - // Delete bookmark(s) - if len(ids) == 0 { - tx.MustExec(delBookmarkTag) - tx.MustExec(delBookmark) - } else { - delBookmark += ` WHERE id = $1` - delBookmarkTag += ` WHERE bookmark_id = $1` - - stmtDelBookmark, _ := tx.Preparex(delBookmark) - stmtDelBookmarkTag, _ := tx.Preparex(delBookmarkTag) - - for _, id := range ids { - stmtDelBookmarkTag.MustExec(id) - stmtDelBookmark.MustExec(id) } - } - // Commit transaction - err = tx.Commit() - checkError(err) + return nil + }); err != nil { + return errors.WithStack(err) + } - return err + return nil } // GetBookmark fetchs bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. -func (db *PGDatabase) GetBookmark(id int, url string) (model.Bookmark, bool) { +func (db *PGDatabase) GetBookmark(ctx context.Context, id int, url string) (model.Bookmark, bool, error) { args := []interface{}{id} query := `SELECT id, url, title, excerpt, author, public, @@ -483,15 +506,15 @@ func (db *PGDatabase) GetBookmark(id int, url string) (model.Bookmark, bool) { } book := model.Bookmark{} - if err := db.Get(&book, query, args...); err != nil { - log.Printf("error during db.get: %s", err) + if err := db.GetContext(ctx, &book, query, args...); err != nil { + return book, false, errors.WithStack(err) } - return book, book.ID != 0 + return book, book.ID != 0, nil } // SaveAccount saves new account to database. Returns error if any happened. -func (db *PGDatabase) SaveAccount(account model.Account) (err error) { +func (db *PGDatabase) SaveAccount(ctx context.Context, account model.Account) (err error) { // Hash password with bcrypt hashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), 10) if err != nil { @@ -499,18 +522,18 @@ func (db *PGDatabase) SaveAccount(account model.Account) (err error) { } // Insert account to database - _, err = db.Exec(`INSERT INTO account + _, err = db.ExecContext(ctx, `INSERT INTO account (username, password, owner) VALUES ($1, $2, $3) ON CONFLICT(username) DO UPDATE SET password = $2, owner = $3`, account.Username, hashedPassword, account.Owner) - return err + return errors.WithStack(err) } // GetAccounts fetch list of account (without its password) based on submitted options. -func (db *PGDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, error) { +func (db *PGDatabase) GetAccounts(ctx context.Context, opts GetAccountsOptions) ([]model.Account, error) { // Create query args := []interface{}{} query := `SELECT id, username, owner FROM account WHERE TRUE` @@ -528,9 +551,9 @@ func (db *PGDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, err // Fetch list account accounts := []model.Account{} - err := db.Select(&accounts, query, args...) + err := db.SelectContext(ctx, &accounts, query, args...) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch accounts: %v", err) + return nil, errors.WithStack(err) } return accounts, nil @@ -538,78 +561,71 @@ func (db *PGDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, err // GetAccount fetch account with matching username. // Returns the account and boolean whether it's exist or not. -func (db *PGDatabase) GetAccount(username string) (model.Account, bool) { +func (db *PGDatabase) GetAccount(ctx context.Context, username string) (model.Account, bool, error) { account := model.Account{} - if err := db.Get(&account, `SELECT + if err := db.GetContext(ctx, &account, `SELECT id, username, password, owner FROM account WHERE username = $1`, username, ); err != nil { - log.Printf("error during db.get: %s", err) + return account, false, errors.WithStack(err) } - return account, account.ID != 0 + return account, account.ID != 0, nil } // DeleteAccounts removes all record with matching usernames. -func (db *PGDatabase) DeleteAccounts(usernames ...string) (err error) { - // Begin transaction - tx, err := db.Beginx() - if err != nil { - return err - } - - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) +func (db *PGDatabase) DeleteAccounts(ctx context.Context, usernames ...string) (err error) { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete account + stmtDelete, _ := tx.Preparex(`DELETE FROM account WHERE username = $1`) + for _, username := range usernames { + if _, err := stmtDelete.ExecContext(ctx, username); err != nil { + return errors.WithStack(err) } - err = panicErr } - }() - // Delete account - stmtDelete, _ := tx.Preparex(`DELETE FROM account WHERE username = $1`) - for _, username := range usernames { - stmtDelete.MustExec(username) + return nil + }); err != nil { + return errors.WithStack(err) } - // Commit transaction - err = tx.Commit() - checkError(err) - - return err + return nil } // GetTags fetch list of tags and their frequency. -func (db *PGDatabase) GetTags() ([]model.Tag, error) { +func (db *PGDatabase) GetTags(ctx context.Context) ([]model.Tag, error) { tags := []model.Tag{} query := `SELECT bt.tag_id id, t.name, COUNT(bt.tag_id) n_bookmarks FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id GROUP BY bt.tag_id, t.name ORDER BY t.name` - err := db.Select(&tags, query) + err := db.SelectContext(ctx, &tags, query) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch tags: %v", err) + return nil, errors.WithStack(err) } return tags, nil } // RenameTag change the name of a tag. -func (db *PGDatabase) RenameTag(id int, newName string) error { - _, err := db.Exec(`UPDATE tag SET name = $1 WHERE id = $2`, newName, id) - return err +func (db *PGDatabase) RenameTag(ctx context.Context, id int, newName string) error { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + _, err := db.Exec(`UPDATE tag SET name = $1 WHERE id = $2`, newName, id) + return errors.WithStack(err) + }); err != nil { + return errors.WithStack(err) + } + + return nil } // CreateNewID creates new ID for specified table -func (db *PGDatabase) CreateNewID(table string) (int, error) { +func (db *PGDatabase) CreateNewID(ctx context.Context, table string) (int, error) { var tableID int query := fmt.Sprintf(`SELECT last_value from %s_id_seq;`, table) - err := db.Get(&tableID, query) + err := db.GetContext(ctx, &tableID, query) if err != nil && err != sql.ErrNoRows { return -1, err } diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index d77c9f7dc..3f9d2337f 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -1,6 +1,7 @@ package database import ( + "context" "database/sql" "fmt" "log" @@ -12,13 +13,14 @@ import ( "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jmoiron/sqlx" + "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" ) // SQLiteDatabase is implementation of Database interface // for connecting to SQLite3 database. type SQLiteDatabase struct { - sqlx.DB + dbbase } type bookmarkContent struct { @@ -33,20 +35,28 @@ type tagContent struct { } // OpenSQLiteDatabase creates and open connection to new SQLite3 database. -func OpenSQLiteDatabase(databasePath string) (sqliteDB *SQLiteDatabase, err error) { +func OpenSQLiteDatabase(ctx context.Context, databasePath string) (sqliteDB *SQLiteDatabase, err error) { // Open database - db := sqlx.MustConnect("sqlite", databasePath) - sqliteDB = &SQLiteDatabase{*db} + db, err := sqlx.ConnectContext(ctx, "sqlite", databasePath) + if err != nil { + return nil, errors.WithStack(err) + } + + sqliteDB = &SQLiteDatabase{dbbase: dbbase{*db}} return sqliteDB, err } // Migrate runs migrations for this database engine func (db *SQLiteDatabase) Migrate() error { sourceDriver, err := iofs.New(migrations, "migrations/sqlite") - checkError(err) + if err != nil { + return errors.WithStack(err) + } dbDriver, err := sqlite.WithInstance(db.DB.DB, &sqlite.Config{}) - checkError(err) + if err != nil { + return errors.WithStack(err) + } migration, err := migrate.NewWithInstance( "iofs", @@ -54,143 +64,175 @@ func (db *SQLiteDatabase) Migrate() error { "sqlite", dbDriver, ) - - checkError(err) + if err != nil { + return errors.WithStack(err) + } return migration.Up() } // SaveBookmarks saves new or updated bookmarks to database. // Returns the saved ID and error message if any happened. -func (db *SQLiteDatabase) SaveBookmarks(bookmarks ...model.Bookmark) (result []model.Bookmark, err error) { - // Prepare transaction - tx, err := db.Beginx() - if err != nil { - return []model.Bookmark{}, err - } - - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) - } - result = []model.Bookmark{} - err = panicErr +func (db *SQLiteDatabase) SaveBookmarks(ctx context.Context, bookmarks ...model.Bookmark) ([]model.Bookmark, error) { + var result []model.Bookmark + + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Prepare statement + stmtInsertBook, err := tx.PreparexContext(ctx, `INSERT INTO bookmark + (id, url, title, excerpt, author, public, modified, has_content) + VALUES(?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + url = ?, title = ?, excerpt = ?, author = ?, + public = ?, modified = ?, has_content = ?`) + if err != nil { + return errors.WithStack(err) } - }() - // Prepare statement - stmtInsertBook, _ := tx.Preparex(`INSERT INTO bookmark - (id, url, title, excerpt, author, public, modified, has_content) - VALUES(?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - url = ?, title = ?, excerpt = ?, author = ?, - public = ?, modified = ?, has_content = ?`) + stmtInsertBookContent, err := tx.PreparexContext(ctx, `INSERT OR REPLACE INTO bookmark_content + (docid, title, content, html) + VALUES (?, ?, ?, ?)`) + if err != nil { + return errors.WithStack(err) + } - stmtInsertBookContent, _ := tx.Preparex(`INSERT OR REPLACE INTO bookmark_content - (docid, title, content, html) - VALUES (?, ?, ?, ?)`) + stmtUpdateBookContent, err := tx.PreparexContext(ctx, `UPDATE bookmark_content SET + title = ?, content = ?, html = ? + WHERE docid = ?`) + if err != nil { + return errors.WithStack(err) + } - stmtUpdateBookContent, _ := tx.Preparex(`UPDATE bookmark_content SET - title = ?, content = ?, html = ? - WHERE docid = ?`) + stmtGetTag, err := tx.PreparexContext(ctx, `SELECT id FROM tag WHERE name = ?`) + if err != nil { + return errors.WithStack(err) + } - stmtGetTag, _ := tx.Preparex(`SELECT id FROM tag WHERE name = ?`) + stmtInsertTag, err := tx.PreparexContext(ctx, `INSERT INTO tag (name) VALUES (?)`) + if err != nil { + return errors.WithStack(err) + } - stmtInsertTag, _ := tx.Preparex(`INSERT INTO tag (name) VALUES (?)`) + stmtInsertBookTag, err := tx.PreparexContext(ctx, `INSERT OR IGNORE INTO bookmark_tag + (tag_id, bookmark_id) VALUES (?, ?)`) + if err != nil { + return errors.WithStack(err) + } - stmtInsertBookTag, _ := tx.Preparex(`INSERT OR IGNORE INTO bookmark_tag - (tag_id, bookmark_id) VALUES (?, ?)`) + stmtDeleteBookTag, err := tx.PreparexContext(ctx, `DELETE FROM bookmark_tag + WHERE bookmark_id = ? AND tag_id = ?`) + if err != nil { + return errors.WithStack(err) + } - stmtDeleteBookTag, _ := tx.Preparex(`DELETE FROM bookmark_tag - WHERE bookmark_id = ? AND tag_id = ?`) + // Prepare modified time + modifiedTime := time.Now().UTC().Format("2006-01-02 15:04:05") - // Prepare modified time - modifiedTime := time.Now().UTC().Format("2006-01-02 15:04:05") + // Execute statements - // Execute statements - result = []model.Bookmark{} - for _, book := range bookmarks { - // Check ID, URL and title - if book.ID == 0 { - panic(fmt.Errorf("ID must not be empty")) - } + for _, book := range bookmarks { + // Check ID, URL and title + if book.ID == 0 { + return errors.New("ID must not be empty") + } - if book.URL == "" { - panic(fmt.Errorf("URL must not be empty")) - } + if book.URL == "" { + return errors.New("URL must not be empty") + } - if book.Title == "" { - panic(fmt.Errorf("title must not be empty")) - } + if book.Title == "" { + return errors.New("title must not be empty") + } - // Set modified time - book.Modified = modifiedTime - - // Save bookmark - hasContent := book.Content != "" - stmtInsertBook.MustExec(book.ID, - book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Modified, hasContent, - book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Modified, hasContent) - - // Try to update it first to check for existence, we can't do an UPSERT here because - // bookmark_content is a virtual table - res := stmtUpdateBookContent.MustExec(book.Title, book.Content, book.HTML, book.ID) - rows, _ := res.RowsAffected() - if rows == 0 { - stmtInsertBookContent.MustExec(book.ID, book.Title, book.Content, book.HTML) - } + // Set modified time + book.Modified = modifiedTime - // Save book tags - newTags := []model.Tag{} - for _, tag := range book.Tags { - // If it's deleted tag, delete and continue - if tag.Deleted { - stmtDeleteBookTag.MustExec(book.ID, tag.ID) - continue + // Save bookmark + hasContent := book.Content != "" + _, err = stmtInsertBook.ExecContext(ctx, book.ID, + book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Modified, hasContent, + book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Modified, hasContent) + if err != nil { + return errors.WithStack(err) } - // Normalize tag name - tagName := strings.ToLower(tag.Name) - tagName = strings.Join(strings.Fields(tagName), " ") + // Try to update it first to check for existence, we can't do an UPSERT here because + // bookmant_content is a virtual table + res, err := stmtUpdateBookContent.ExecContext(ctx, book.Title, book.Content, book.HTML, book.ID) + if err != nil { + return errors.WithStack(err) + } - // If tag doesn't have any ID, fetch it from database - if tag.ID == 0 { - err = stmtGetTag.Get(&tag.ID, tagName) - checkError(err) + rows, err := res.RowsAffected() + if err != nil { + return errors.WithStack(err) + } - // If tag doesn't exist in database, save it - if tag.ID == 0 { - res := stmtInsertTag.MustExec(tagName) - tagID64, err := res.LastInsertId() - checkError(err) + if rows == 0 { + _, err = stmtInsertBookContent.ExecContext(ctx, book.ID, book.Title, book.Content, book.HTML) + if err != nil { + return errors.WithStack(err) + } + } - tag.ID = int(tagID64) + // Save book tags + newTags := []model.Tag{} + for _, tag := range book.Tags { + // If it's deleted tag, delete and continue + if tag.Deleted { + _, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, tag.ID) + if err != nil { + return errors.WithStack(err) + } + continue } - if _, err := stmtInsertBookTag.Exec(tag.ID, book.ID); err != nil { - log.Printf("error during insert: %s", err) + // Normalize tag name + tagName := strings.ToLower(tag.Name) + tagName = strings.Join(strings.Fields(tagName), " ") + + // If tag doesn't have any ID, fetch it from database + if tag.ID == 0 { + if err := stmtGetTag.GetContext(ctx, &tag.ID, tagName); err != nil && err != sql.ErrNoRows { + return errors.WithStack(err) + } + + // If tag doesn't exist in database, save it + if tag.ID == 0 { + res, err := stmtInsertTag.ExecContext(ctx, tagName) + if err != nil { + return errors.WithStack(err) + } + + tagID64, err := res.LastInsertId() + if err != nil && err != sql.ErrNoRows { + return errors.WithStack(err) + } + + tag.ID = int(tagID64) + } + + if _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil { + return errors.WithStack(err) + } } + + newTags = append(newTags, tag) } - newTags = append(newTags, tag) + book.Tags = newTags + result = append(result, book) } - book.Tags = newTags - result = append(result, book) + return nil + }); err != nil { + return nil, errors.WithStack(err) } - // Commit transaction - err = tx.Commit() - checkError(err) - - return result, err + return result, nil } // GetBookmarks fetch list of bookmarks based on submitted options. -func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmark, error) { +func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts GetBookmarksOptions) ([]model.Bookmark, error) { // Create initial query query := `SELECT b.id, @@ -297,14 +339,14 @@ func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookma // Expand query, because some of the args might be an array query, args, err := sqlx.In(query, args...) if err != nil { - return nil, fmt.Errorf("failed to expand query: %v", err) + return nil, errors.WithStack(err) } // Fetch bookmarks bookmarks := []model.Bookmark{} - err = db.Select(&bookmarks, query, args...) + err = db.SelectContext(ctx, &bookmarks, query, args...) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch data: %v", err) + return nil, errors.WithStack(err) } // store bookmark IDs for further enrichment @@ -322,12 +364,12 @@ func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookma contentQuery, args, err := sqlx.In(`SELECT docid, content, html FROM bookmark_content WHERE docid IN (?)`, bookmarkIds) contentQuery = db.Rebind(contentQuery) if err != nil { - return nil, fmt.Errorf("failed to expand bookmark_content query: %v", err) + return nil, errors.WithStack(err) } err = db.Select(&contents, contentQuery, args...) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch content for bookmarks (%v): %v", bookmarkIds, err) + return nil, errors.WithStack(err) } for _, content := range contents { contentMap[content.ID] = content @@ -355,12 +397,12 @@ func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookma ORDER BY t.name`, bookmarkIds) tagsQuery = db.Rebind(tagsQuery) if err != nil { - return nil, fmt.Errorf("failed to expand bookmark_tag query: %v", err) + return nil, errors.WithStack(err) } err = db.Select(&tags, tagsQuery, tagArgs...) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch tags for bookmarks (%v): %v", bookmarkIds, err) + return nil, errors.WithStack(err) } for _, fetchedTag := range tags { if tags, found := tagsMap[fetchedTag.ID]; found { @@ -382,7 +424,7 @@ func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookma } // GetBookmarksCount fetch count of bookmarks based on submitted options. -func (db *SQLiteDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, error) { +func (db *SQLiteDatabase) GetBookmarksCount(ctx context.Context, opts GetBookmarksOptions) (int, error) { // Create initial query query := `SELECT COUNT(b.id) FROM bookmark b @@ -466,74 +508,92 @@ func (db *SQLiteDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, erro // Expand query, because some of the args might be an array query, args, err := sqlx.In(query, args...) if err != nil { - return 0, fmt.Errorf("failed to expand query: %v", err) + return 0, errors.WithStack(err) } // Fetch count var nBookmarks int - err = db.Get(&nBookmarks, query, args...) + err = db.GetContext(ctx, &nBookmarks, query, args...) if err != nil && err != sql.ErrNoRows { - return 0, fmt.Errorf("failed to fetch count: %v", err) + return 0, errors.WithStack(err) } return nBookmarks, nil } // DeleteBookmarks removes all record with matching ids from database. -func (db *SQLiteDatabase) DeleteBookmarks(ids ...int) (err error) { - // Begin transaction - tx, err := db.Beginx() - if err != nil { - return err - } +func (db *SQLiteDatabase) DeleteBookmarks(ctx context.Context, ids ...int) error { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Prepare queries + delBookmark := `DELETE FROM bookmark` + delBookmarkTag := `DELETE FROM bookmark_tag` + delBookmarkContent := `DELETE FROM bookmark_content` + + // Delete bookmark(s) + if len(ids) == 0 { + _, err := tx.ExecContext(ctx, delBookmarkContent) + if err != nil { + return errors.WithStack(err) + } - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) + _, err = tx.ExecContext(ctx, delBookmarkTag) + if err != nil { + return errors.WithStack(err) + } + + _, err = tx.ExecContext(ctx, delBookmark) + if err != nil { + return errors.WithStack(err) + } + } else { + delBookmark += ` WHERE id = ?` + delBookmarkTag += ` WHERE bookmark_id = ?` + delBookmarkContent += ` WHERE docid = ?` + + stmtDelBookmark, err := tx.Preparex(delBookmark) + if err != nil { + return errors.WithStack(err) + } + + stmtDelBookmarkTag, err := tx.Preparex(delBookmarkTag) + if err != nil { + return errors.WithStack(err) + } + + stmtDelBookmarkContent, err := tx.Preparex(delBookmarkContent) + if err != nil { + return errors.WithStack(err) + } + + for _, id := range ids { + _, err = stmtDelBookmarkContent.ExecContext(ctx, id) + if err != nil { + return errors.WithStack(err) + } + + _, err = stmtDelBookmarkTag.ExecContext(ctx, id) + if err != nil { + return errors.WithStack(err) + } + + _, err = stmtDelBookmark.ExecContext(ctx, id) + if err != nil { + return errors.WithStack(err) + } } - err = panicErr - } - }() - - // Prepare queries - delBookmark := `DELETE FROM bookmark` - delBookmarkTag := `DELETE FROM bookmark_tag` - delBookmarkContent := `DELETE FROM bookmark_content` - - // Delete bookmark(s) - if len(ids) == 0 { - tx.MustExec(delBookmarkContent) - tx.MustExec(delBookmarkTag) - tx.MustExec(delBookmark) - } else { - delBookmark += ` WHERE id = ?` - delBookmarkTag += ` WHERE bookmark_id = ?` - delBookmarkContent += ` WHERE docid = ?` - - stmtDelBookmark, _ := tx.Preparex(delBookmark) - stmtDelBookmarkTag, _ := tx.Preparex(delBookmarkTag) - stmtDelBookmarkContent, _ := tx.Preparex(delBookmarkContent) - - for _, id := range ids { - stmtDelBookmarkContent.MustExec(id) - stmtDelBookmarkTag.MustExec(id) - stmtDelBookmark.MustExec(id) } - } - // Commit transaction - err = tx.Commit() - checkError(err) + return nil + }); err != nil { + return errors.WithStack(err) + } - return err + return nil } // GetBookmark fetches bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. -func (db *SQLiteDatabase) GetBookmark(id int, url string) (model.Bookmark, bool) { +func (db *SQLiteDatabase) GetBookmark(ctx context.Context, id int, url string) (model.Bookmark, bool, error) { args := []interface{}{id} query := `SELECT b.id, b.url, b.title, b.excerpt, b.author, b.public, b.modified, @@ -548,34 +608,39 @@ func (db *SQLiteDatabase) GetBookmark(id int, url string) (model.Bookmark, bool) } book := model.Bookmark{} - if err := db.Get(&book, query, args...); err != nil { - log.Printf("error during db.get: %s", err) + if err := db.GetContext(ctx, &book, query, args...); err != nil { + return book, false, errors.WithStack(err) } - return book, book.ID != 0 + return book, book.ID != 0, nil } // SaveAccount saves new account to database. Returns error if any happened. -func (db *SQLiteDatabase) SaveAccount(account model.Account) (err error) { - // Hash password with bcrypt - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), 10) - if err != nil { - return err - } +func (db *SQLiteDatabase) SaveAccount(ctx context.Context, account model.Account) error { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Hash password with bcrypt + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), 10) + if err != nil { + return err + } - // Insert account to database - _, err = db.Exec(`INSERT INTO account + // Insert account to database + _, err = tx.Exec(`INSERT INTO account (username, password, owner) VALUES (?, ?, ?) ON CONFLICT(username) DO UPDATE SET password = ?, owner = ?`, - account.Username, hashedPassword, account.Owner, - hashedPassword, account.Owner) + account.Username, hashedPassword, account.Owner, + hashedPassword, account.Owner) + return errors.WithStack(err) + }); err != nil { + return errors.WithStack(err) + } - return err + return nil } // GetAccounts fetch list of account (without its password) based on submitted options. -func (db *SQLiteDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, error) { +func (db *SQLiteDatabase) GetAccounts(ctx context.Context, opts GetAccountsOptions) ([]model.Account, error) { // Create query args := []interface{}{} query := `SELECT id, username, owner FROM account WHERE 1` @@ -593,9 +658,9 @@ func (db *SQLiteDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, // Fetch list account accounts := []model.Account{} - err := db.Select(&accounts, query, args...) + err := db.SelectContext(ctx, &accounts, query, args...) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch accounts: %v", err) + return nil, errors.WithStack(err) } return accounts, nil @@ -603,80 +668,78 @@ func (db *SQLiteDatabase) GetAccounts(opts GetAccountsOptions) ([]model.Account, // GetAccount fetch account with matching username. // Returns the account and boolean whether it's exist or not. -func (db *SQLiteDatabase) GetAccount(username string) (model.Account, bool) { +func (db *SQLiteDatabase) GetAccount(ctx context.Context, username string) (model.Account, bool, error) { account := model.Account{} - if err := db.Get(&account, `SELECT + if err := db.GetContext(ctx, &account, `SELECT id, username, password, owner FROM account WHERE username = ?`, username, ); err != nil { - log.Printf("error during db.get: %s", err) + return account, false, errors.WithStack(err) } - return account, account.ID != 0 + return account, account.ID != 0, nil } // DeleteAccounts removes all record with matching usernames. -func (db *SQLiteDatabase) DeleteAccounts(usernames ...string) (err error) { - // Begin transaction - tx, err := db.Beginx() - if err != nil { - return err - } +func (db *SQLiteDatabase) DeleteAccounts(ctx context.Context, usernames ...string) error { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + // Delete account + stmtDelete, err := tx.Preparex(`DELETE FROM account WHERE username = ?`) + if err != nil { + return errors.WithStack(err) + } - // Make sure to rollback if panic ever happened - defer func() { - if r := recover(); r != nil { - panicErr, _ := r.(error) - if err := tx.Rollback(); err != nil { - log.Printf("error during rollback: %s", err) + for _, username := range usernames { + _, err := stmtDelete.ExecContext(ctx, username) + if err != nil { + return errors.WithStack(err) } - err = panicErr } - }() - // Delete account - stmtDelete, _ := tx.Preparex(`DELETE FROM account WHERE username = ?`) - for _, username := range usernames { - stmtDelete.MustExec(username) + return nil + }); err != nil { + return errors.WithStack(err) } - // Commit transaction - err = tx.Commit() - checkError(err) - - return err + return nil } // GetTags fetch list of tags and their frequency. -func (db *SQLiteDatabase) GetTags() ([]model.Tag, error) { +func (db *SQLiteDatabase) GetTags(ctx context.Context) ([]model.Tag, error) { tags := []model.Tag{} query := `SELECT bt.tag_id id, t.name, COUNT(bt.tag_id) n_bookmarks FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id GROUP BY bt.tag_id ORDER BY t.name` - err := db.Select(&tags, query) + err := db.SelectContext(ctx, &tags, query) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("failed to fetch tags: %v", err) + return nil, errors.WithStack(err) } return tags, nil } // RenameTag change the name of a tag. -func (db *SQLiteDatabase) RenameTag(id int, newName string) error { - _, err := db.Exec(`UPDATE tag SET name = ? WHERE id = ?`, newName, id) - return err +func (db *SQLiteDatabase) RenameTag(ctx context.Context, id int, newName string) error { + if err := db.withTx(ctx, func(tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, `UPDATE tag SET name = ? WHERE id = ?`, newName, id) + return err + }); err != nil { + return errors.WithStack(err) + } + + return nil } // CreateNewID creates new ID for specified table -func (db *SQLiteDatabase) CreateNewID(table string) (int, error) { +func (db *SQLiteDatabase) CreateNewID(ctx context.Context, table string) (int, error) { var tableID int query := fmt.Sprintf(`SELECT IFNULL(MAX(id) + 1, 1) FROM %s`, table) - err := db.Get(&tableID, query) + err := db.GetContext(ctx, &tableID, query) if err != nil && err != sql.ErrNoRows { - return -1, err + return -1, errors.WithStack(err) } return tableID, nil diff --git a/internal/webserver/handler-api-ext.go b/internal/webserver/handler-api-ext.go index b25c795be..c62935880 100644 --- a/internal/webserver/handler-api-ext.go +++ b/internal/webserver/handler-api-ext.go @@ -18,6 +18,8 @@ import ( // apiInsertViaExtension is handler for POST /api/bookmarks/ext func (h *handler) apiInsertViaExtension(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -34,7 +36,10 @@ func (h *handler) apiInsertViaExtension(w http.ResponseWriter, r *http.Request, } // Check if bookmark already exists. - book, exist := h.DB.GetBookmark(0, request.URL) + book, exist, err := h.DB.GetBookmark(ctx, 0, request.URL) + if err != nil { + panic(fmt.Errorf("failed to get bookmark, URL: %v", err)) + } // If it already exists, we need to set ID and tags. if exist { @@ -52,7 +57,7 @@ func (h *handler) apiInsertViaExtension(w http.ResponseWriter, r *http.Request, } } else { book = request - book.ID, err = h.DB.CreateNewID("bookmark") + book.ID, err = h.DB.CreateNewID(ctx, "bookmark") if err != nil { panic(fmt.Errorf("failed to create ID: %v", err)) } @@ -93,12 +98,12 @@ func (h *handler) apiInsertViaExtension(w http.ResponseWriter, r *http.Request, panic(fmt.Errorf("failed to process bookmark: %v", err)) } } - if _, err := h.DB.SaveBookmarks(book); err != nil { + if _, err := h.DB.SaveBookmarks(ctx, book); err != nil { log.Printf("error saving bookmark after downloading content: %s", err) } // Save bookmark to database - results, err := h.DB.SaveBookmarks(book) + results, err := h.DB.SaveBookmarks(ctx, book) if err != nil || len(results) == 0 { panic(fmt.Errorf("failed to save bookmark: %v", err)) } @@ -112,6 +117,8 @@ func (h *handler) apiInsertViaExtension(w http.ResponseWriter, r *http.Request, // apiDeleteViaExtension is handler for DELETE /api/bookmark/ext func (h *handler) apiDeleteViaExtension(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -122,10 +129,12 @@ func (h *handler) apiDeleteViaExtension(w http.ResponseWriter, r *http.Request, checkError(err) // Check if bookmark already exists. - book, exist := h.DB.GetBookmark(0, request.URL) + book, exist, err := h.DB.GetBookmark(ctx, 0, request.URL) + checkError(err) + if exist { // Delete bookmarks - err = h.DB.DeleteBookmarks(book.ID) + err = h.DB.DeleteBookmarks(ctx, book.ID) checkError(err) // Delete thumbnail image and archives from local disk diff --git a/internal/webserver/handler-api.go b/internal/webserver/handler-api.go index c3e9620b9..dd31f39db 100644 --- a/internal/webserver/handler-api.go +++ b/internal/webserver/handler-api.go @@ -47,6 +47,8 @@ func downloadBookmarkContent(book *model.Bookmark, dataDir string, request *http // apiLogin is handler for POST /api/login func (h *handler) apiLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Decode request request := struct { Username string `json:"username"` @@ -96,7 +98,7 @@ func (h *handler) apiLogin(w http.ResponseWriter, r *http.Request, ps httprouter Owner: true, } - accounts, err := h.DB.GetAccounts(searchOptions) + accounts, err := h.DB.GetAccounts(ctx, searchOptions) checkError(err) if len(accounts) == 0 && request.Username == "shiori" && request.Password == "gopher" { @@ -108,7 +110,9 @@ func (h *handler) apiLogin(w http.ResponseWriter, r *http.Request, ps httprouter } // Get account data from database - account, exist := h.DB.GetAccount(request.Username) + account, exist, err := h.DB.GetAccount(ctx, request.Username) + checkError(err) + if !exist { panic(fmt.Errorf("username doesn't exist")) } @@ -147,6 +151,8 @@ func (h *handler) apiLogout(w http.ResponseWriter, r *http.Request, ps httproute // apiGetBookmarks is handler for GET /api/bookmarks func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -183,12 +189,12 @@ func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps htt } // Calculate max page - nBookmarks, err := h.DB.GetBookmarksCount(searchOptions) + nBookmarks, err := h.DB.GetBookmarksCount(ctx, searchOptions) checkError(err) maxPage := int(math.Ceil(float64(nBookmarks) / 30)) // Fetch all matching bookmarks - bookmarks, err := h.DB.GetBookmarks(searchOptions) + bookmarks, err := h.DB.GetBookmarks(ctx, searchOptions) checkError(err) // Get image URL for each bookmark, and check if it has archive @@ -220,12 +226,14 @@ func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps htt // apiGetTags is handler for GET /api/tags func (h *handler) apiGetTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) // Fetch all tags - tags, err := h.DB.GetTags() + tags, err := h.DB.GetTags(ctx) checkError(err) w.Header().Set("Content-Type", "application/json") @@ -235,6 +243,8 @@ func (h *handler) apiGetTags(w http.ResponseWriter, r *http.Request, ps httprout // apiRenameTag is handler for PUT /api/tag func (h *handler) apiRenameTag(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -245,7 +255,7 @@ func (h *handler) apiRenameTag(w http.ResponseWriter, r *http.Request, ps httpro checkError(err) // Update name - err = h.DB.RenameTag(tag.ID, tag.Name) + err = h.DB.RenameTag(ctx, tag.ID, tag.Name) checkError(err) fmt.Fprint(w, 1) @@ -273,6 +283,8 @@ func newAPIInsertBookmarkPayload() *apiInsertBookmarkPayload { // apiInsertBookmark is handler for POST /api/bookmark func (h *handler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -292,7 +304,7 @@ func (h *handler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, ps h } // Create bookmark ID - book.ID, err = h.DB.CreateNewID("bookmark") + book.ID, err = h.DB.CreateNewID(ctx, "bookmark") if err != nil { panic(fmt.Errorf("failed to create ID: %v", err)) } @@ -316,7 +328,7 @@ func (h *handler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, ps h } // Save bookmark to database - results, err := h.DB.SaveBookmarks(*book) + results, err := h.DB.SaveBookmarks(ctx, *book) if err != nil || len(results) == 0 { panic(fmt.Errorf("failed to save bookmark: %v", err)) } @@ -327,7 +339,7 @@ func (h *handler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, ps h if err != nil { log.Printf("error downloading boorkmark: %s", err) } - if _, err := h.DB.SaveBookmarks(*bookmark); err != nil { + if _, err := h.DB.SaveBookmarks(ctx, *bookmark); err != nil { log.Printf("failed to save bookmark: %s", err) } }() @@ -341,6 +353,8 @@ func (h *handler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, ps h // apiDeleteBookmarks is handler for DELETE /api/bookmark func (h *handler) apiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -351,7 +365,7 @@ func (h *handler) apiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps h checkError(err) // Delete bookmarks - err = h.DB.DeleteBookmarks(ids...) + err = h.DB.DeleteBookmarks(ctx, ids...) checkError(err) // Delete thumbnail image and archives from local disk @@ -369,6 +383,8 @@ func (h *handler) apiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps h // apiUpdateBookmark is handler for PUT /api/bookmarks func (h *handler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -380,7 +396,7 @@ func (h *handler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps h // Validate input if request.Title == "" { - panic(fmt.Errorf("Title must not empty")) + panic(fmt.Errorf("title must not empty")) } // Get existing bookmark from database @@ -389,7 +405,7 @@ func (h *handler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps h WithContent: true, } - bookmarks, err := h.DB.GetBookmarks(filter) + bookmarks, err := h.DB.GetBookmarks(ctx, filter) checkError(err) if len(bookmarks) == 0 { panic(fmt.Errorf("no bookmark with matching ids")) @@ -428,7 +444,7 @@ func (h *handler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps h } // Update database - res, err := h.DB.SaveBookmarks(book) + res, err := h.DB.SaveBookmarks(ctx, book) checkError(err) // Add thumbnail image to the saved bookmarks again @@ -444,6 +460,8 @@ func (h *handler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps h // apiUpdateCache is handler for PUT /api/cache func (h *handler) apiUpdateCache(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -464,7 +482,7 @@ func (h *handler) apiUpdateCache(w http.ResponseWriter, r *http.Request, ps http WithContent: true, } - bookmarks, err := h.DB.GetBookmarks(filter) + bookmarks, err := h.DB.GetBookmarks(ctx, filter) checkError(err) if len(bookmarks) == 0 { panic(fmt.Errorf("no bookmark with matching ids")) @@ -550,7 +568,7 @@ func (h *handler) apiUpdateCache(w http.ResponseWriter, r *http.Request, ps http close(chDone) // Update database - _, err = h.DB.SaveBookmarks(bookmarks...) + _, err = h.DB.SaveBookmarks(ctx, bookmarks...) checkError(err) // Return new saved result @@ -561,6 +579,8 @@ func (h *handler) apiUpdateCache(w http.ResponseWriter, r *http.Request, ps http // apiUpdateBookmarkTags is handler for PUT /api/bookmarks/tags func (h *handler) apiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -585,7 +605,7 @@ func (h *handler) apiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request, WithContent: true, } - bookmarks, err := h.DB.GetBookmarks(filter) + bookmarks, err := h.DB.GetBookmarks(ctx, filter) checkError(err) if len(bookmarks) == 0 { panic(fmt.Errorf("no bookmark with matching ids")) @@ -610,7 +630,7 @@ func (h *handler) apiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request, } // Update database - bookmarks, err = h.DB.SaveBookmarks(bookmarks...) + bookmarks, err = h.DB.SaveBookmarks(ctx, bookmarks...) checkError(err) // Get image URL for each bookmark @@ -631,12 +651,14 @@ func (h *handler) apiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request, // apiGetAccounts is handler for GET /api/accounts func (h *handler) apiGetAccounts(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) // Get list of usernames from database - accounts, err := h.DB.GetAccounts(database.GetAccountsOptions{}) + accounts, err := h.DB.GetAccounts(ctx, database.GetAccountsOptions{}) checkError(err) w.Header().Set("Content-Type", "application/json") @@ -646,6 +668,8 @@ func (h *handler) apiGetAccounts(w http.ResponseWriter, r *http.Request, ps http // apiInsertAccount is handler for POST /api/accounts func (h *handler) apiInsertAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -656,7 +680,7 @@ func (h *handler) apiInsertAccount(w http.ResponseWriter, r *http.Request, ps ht checkError(err) // Save account to database - err = h.DB.SaveAccount(account) + err = h.DB.SaveAccount(ctx, account) checkError(err) fmt.Fprint(w, 1) @@ -664,6 +688,8 @@ func (h *handler) apiInsertAccount(w http.ResponseWriter, r *http.Request, ps ht // apiUpdateAccount is handler for PUT /api/accounts func (h *handler) apiUpdateAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -680,7 +706,9 @@ func (h *handler) apiUpdateAccount(w http.ResponseWriter, r *http.Request, ps ht checkError(err) // Get existing account data from database - account, exist := h.DB.GetAccount(request.Username) + account, exist, err := h.DB.GetAccount(ctx, request.Username) + checkError(err) + if !exist { panic(fmt.Errorf("username doesn't exist")) } @@ -694,7 +722,7 @@ func (h *handler) apiUpdateAccount(w http.ResponseWriter, r *http.Request, ps ht // Save new password to database account.Password = request.NewPassword account.Owner = request.Owner - err = h.DB.SaveAccount(account) + err = h.DB.SaveAccount(ctx, account) checkError(err) // Delete user's sessions @@ -712,6 +740,8 @@ func (h *handler) apiUpdateAccount(w http.ResponseWriter, r *http.Request, ps ht // apiDeleteAccount is handler for DELETE /api/accounts func (h *handler) apiDeleteAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Make sure session still valid err := h.validateSession(r) checkError(err) @@ -722,7 +752,7 @@ func (h *handler) apiDeleteAccount(w http.ResponseWriter, r *http.Request, ps ht checkError(err) // Delete accounts - err = h.DB.DeleteAccounts(usernames...) + err = h.DB.DeleteAccounts(ctx, usernames...) checkError(err) // Delete user's sessions diff --git a/internal/webserver/handler-ui.go b/internal/webserver/handler-ui.go index 4d4eb25d4..9c18973af 100644 --- a/internal/webserver/handler-ui.go +++ b/internal/webserver/handler-ui.go @@ -92,15 +92,19 @@ func (h *handler) serveLoginPage(w http.ResponseWriter, r *http.Request, ps http // serveBookmarkContent is handler for GET /bookmark/:id/content func (h *handler) serveBookmarkContent(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Get bookmark ID from URL strID := ps.ByName("id") id, err := strconv.Atoi(strID) checkError(err) // Get bookmark in database - bookmark, exist := h.DB.GetBookmark(id, "") + bookmark, exist, err := h.DB.GetBookmark(ctx, id, "") + checkError(err) + if !exist { - panic(fmt.Errorf("Bookmark not found")) + panic(fmt.Errorf("bookmark not found")) } // If it's not public, make sure session still valid @@ -235,6 +239,8 @@ func (h *handler) serveThumbnailImage(w http.ResponseWriter, r *http.Request, ps // serveBookmarkArchive is handler for GET /bookmark/:id/archive/*filepath func (h *handler) serveBookmarkArchive(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ctx := r.Context() + // Get parameter from URL strID := ps.ByName("id") resourcePath := ps.ByName("filepath") @@ -244,9 +250,11 @@ func (h *handler) serveBookmarkArchive(w http.ResponseWriter, r *http.Request, p id, err := strconv.Atoi(strID) checkError(err) - bookmark, exist := h.DB.GetBookmark(id, "") + bookmark, exist, err := h.DB.GetBookmark(ctx, id, "") + checkError(err) + if !exist { - panic(fmt.Errorf("Bookmark not found")) + panic(fmt.Errorf("bookmark not found")) } // If it's not public, make sure session still valid diff --git a/internal/webserver/utils.go b/internal/webserver/utils.go index 2af7334b0..fca0ddf2f 100644 --- a/internal/webserver/utils.go +++ b/internal/webserver/utils.go @@ -84,7 +84,7 @@ func redirectPage(w http.ResponseWriter, r *http.Request, url string) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") - http.Redirect(w, r, url, 301) + http.Redirect(w, r, url, http.StatusMovedPermanently) } func assetExists(filePath string) bool {