package api import ( "crypto/md5" "fmt" "mime/multipart" "net/http" "os" "path/filepath" "strings" "time" argon2 "github.com/alexedwards/argon2id" "github.com/gabriel-vasile/mimetype" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "golang.org/x/exp/slices" "reichard.io/bbank/database" "reichard.io/bbank/metadata" ) type queryParams struct { Page *int64 `form:"page"` Limit *int64 `form:"limit"` Document *string `form:"document"` } type requestDocumentEdit struct { Title *string `form:"title"` Author *string `form:"author"` Description *string `form:"description"` ISBN10 *string `form:"isbn_10"` ISBN13 *string `form:"isbn_13"` RemoveCover *string `form:"remove_cover"` CoverGBID *string `form:"cover_gbid"` CoverFile *multipart.FileHeader `form:"cover_file"` } type requestDocumentIdentify struct { Title *string `form:"title"` Author *string `form:"author"` ISBN *string `form:"isbn"` } type requestSettingsEdit struct { Password *string `form:"password"` NewPassword *string `form:"new_password"` TimeOffset *string `form:"time_offset"` } func baseResourceRoute(template string, args ...map[string]any) func(c *gin.Context) { variables := gin.H{"RouteName": template} if len(args) > 0 { variables = args[0] } return func(c *gin.Context) { rUser, _ := c.Get("AuthorizedUser") variables["User"] = rUser c.HTML(http.StatusOK, template, variables) } } func (api *API) webManifest(c *gin.Context) { c.Header("Content-Type", "application/manifest+json") c.File("./assets/manifest.json") } func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any) func(*gin.Context) { // Merge Optional Template Data var templateVarsBase = gin.H{} if len(args) > 0 { templateVarsBase = args[0] } templateVarsBase["RouteName"] = routeName return func(c *gin.Context) { var userID string if rUser, _ := c.Get("AuthorizedUser"); rUser != nil { userID = rUser.(string) } // Copy Base & Update templateVars := gin.H{} for k, v := range templateVarsBase { templateVars[k] = v } templateVars["User"] = userID // Potential URL Parameters qParams := bindQueryParams(c) if routeName == "documents" { documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{ UserID: userID, Offset: (*qParams.Page - 1) * *qParams.Limit, Limit: *qParams.Limit, }) if err != nil { log.Error("[createAppResourcesRoute] GetDocumentsWithStats DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } if err = api.getDocumentsWordCount(documents); err != nil { log.Error("[createAppResourcesRoute] Unable to Get Word Counts: ", err) } templateVars["Data"] = documents } else if routeName == "document" { var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { log.Error("[createAppResourcesRoute] Invalid URI Bind") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{ UserID: userID, DocumentID: rDocID.DocumentID, }) if err != nil { log.Error("[createAppResourcesRoute] GetDocumentWithStats DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } statistics := gin.H{ "TotalTimeLeftSeconds": (document.Pages - document.Page) * document.SecondsPerPage, "WordsPerMinute": "N/A", } if document.Words != nil && *document.Words != 0 { statistics["WordsPerMinute"] = (*document.Words / document.Pages * document.ReadPages) / (document.TotalTimeSeconds / 60.0) } templateVars["RelBase"] = "../" templateVars["Data"] = document templateVars["Statistics"] = statistics } else if routeName == "activity" { activityFilter := database.GetActivityParams{ UserID: userID, Offset: (*qParams.Page - 1) * *qParams.Limit, Limit: *qParams.Limit, } if qParams.Document != nil { activityFilter.DocFilter = true activityFilter.DocumentID = *qParams.Document } activity, err := api.DB.Queries.GetActivity(api.DB.Ctx, activityFilter) if err != nil { log.Error("[createAppResourcesRoute] GetActivity DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } templateVars["Data"] = activity } else if routeName == "home" { start_time := time.Now() weekly_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{ UserID: userID, Window: "WEEK", }) if err != nil { log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err) } log.Debug("GetUserWindowStreaks - WEEK - ", time.Since(start_time)) start_time = time.Now() daily_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{ UserID: userID, Window: "DAY", }) if err != nil { log.Warn("[createAppResourcesRoute] GetUserWindowStreaks DB Error:", err) } log.Debug("GetUserWindowStreaks - DAY - ", time.Since(start_time)) start_time = time.Now() database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, userID) log.Debug("GetDatabaseInfo - ", time.Since(start_time)) start_time = time.Now() read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, userID) log.Debug("GetDailyReadStats - ", time.Since(start_time)) templateVars["Data"] = gin.H{ "DailyStreak": daily_streak, "WeeklyStreak": weekly_streak, "DatabaseInfo": database_info, "GraphData": read_graph_data, } } else if routeName == "settings" { user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID) if err != nil { log.Error("[createAppResourcesRoute] GetUser DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, userID) if err != nil { log.Error("[createAppResourcesRoute] GetDevices DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } templateVars["Data"] = gin.H{ "Settings": gin.H{ "TimeOffset": *user.TimeOffset, }, "Devices": devices, } } else if routeName == "login" { templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled } c.HTML(http.StatusOK, routeName, templateVars) } } func (api *API) getDocumentCover(c *gin.Context) { var rDoc requestDocumentID if err := c.ShouldBindUri(&rDoc); err != nil { log.Error("[getDocumentCover] Invalid URI Bind") c.AbortWithStatus(http.StatusBadRequest) return } // Validate Document Exists in DB document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID) if err != nil { log.Error("[getDocumentCover] GetDocument DB Error:", err) c.AbortWithStatus(http.StatusBadRequest) return } // Handle Identified Document if document.Coverfile != nil { if *document.Coverfile == "UNKNOWN" { c.File("./assets/no-cover.jpg") return } // Derive Path safePath := filepath.Join(api.Config.DataPath, "covers", *document.Coverfile) // Validate File Exists _, err = os.Stat(safePath) if err != nil { log.Error("[getDocumentCover] File Should But Doesn't Exist:", err) c.File("./assets/no-cover.jpg") return } c.File(safePath) return } // --- Attempt Metadata --- var coverDir string = filepath.Join(api.Config.DataPath, "covers") var coverFile string = "UNKNOWN" // Identify Documents & Save Covers metadataResults, err := metadata.SearchMetadata(metadata.GBOOK, metadata.MetadataInfo{ Title: document.Title, Author: document.Author, }) if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil { firstResult := metadataResults[0] // Save Cover fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false) if err == nil { coverFile = *fileName } // Store First Metadata Result if _, err = api.DB.Queries.AddMetadata(api.DB.Ctx, database.AddMetadataParams{ DocumentID: document.ID, Title: firstResult.Title, Author: firstResult.Author, Description: firstResult.Description, Gbid: firstResult.ID, Olid: nil, Isbn10: firstResult.ISBN10, Isbn13: firstResult.ISBN13, }); err != nil { log.Error("[getDocumentCover] AddMetadata DB Error:", err) } } // Upsert Document if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ ID: document.ID, Coverfile: &coverFile, }); err != nil { log.Warn("[getDocumentCover] UpsertDocument DB Error:", err) } // Return Unknown Cover if coverFile == "UNKNOWN" { c.File("./assets/no-cover.jpg") return } coverFilePath := filepath.Join(coverDir, coverFile) c.File(coverFilePath) } func (api *API) editDocument(c *gin.Context) { var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { log.Error("[createAppResourcesRoute] Invalid URI Bind") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } var rDocEdit requestDocumentEdit if err := c.ShouldBind(&rDocEdit); err != nil { log.Error("[createAppResourcesRoute] Invalid Form Bind") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } // Validate Something Exists if rDocEdit.Author == nil && rDocEdit.Title == nil && rDocEdit.Description == nil && rDocEdit.ISBN10 == nil && rDocEdit.ISBN13 == nil && rDocEdit.RemoveCover == nil && rDocEdit.CoverGBID == nil && rDocEdit.CoverFile == nil { log.Error("[createAppResourcesRoute] Missing Form Values") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } // Handle Cover var coverFileName *string if rDocEdit.RemoveCover != nil && *rDocEdit.RemoveCover == "on" { s := "UNKNOWN" coverFileName = &s } else if rDocEdit.CoverFile != nil { // Validate Type & Derive Extension on MIME uploadedFile, err := rDocEdit.CoverFile.Open() if err != nil { log.Error("[createAppResourcesRoute] File Error") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } fileMime, err := mimetype.DetectReader(uploadedFile) if err != nil { log.Error("[createAppResourcesRoute] MIME Error") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } fileExtension := fileMime.Extension() // Validate Extension if !slices.Contains([]string{".jpg", ".png"}, fileExtension) { log.Error("[uploadDocumentFile] Invalid FileType: ", fileExtension) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"}) return } // Generate Storage Path fileName := fmt.Sprintf("%s%s", rDocID.DocumentID, fileExtension) safePath := filepath.Join(api.Config.DataPath, "covers", fileName) // Save err = c.SaveUploadedFile(rDocEdit.CoverFile, safePath) if err != nil { log.Error("[createAppResourcesRoute] File Error") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } coverFileName = &fileName } else if rDocEdit.CoverGBID != nil { var coverDir string = filepath.Join(api.Config.DataPath, "covers") fileName, err := metadata.CacheCover(*rDocEdit.CoverGBID, coverDir, rDocID.DocumentID, true) if err == nil { coverFileName = fileName } } // Update Document if _, err := api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ ID: rDocID.DocumentID, Title: api.sanitizeInput(rDocEdit.Title), Author: api.sanitizeInput(rDocEdit.Author), Description: api.sanitizeInput(rDocEdit.Description), Isbn10: api.sanitizeInput(rDocEdit.ISBN10), Isbn13: api.sanitizeInput(rDocEdit.ISBN13), Coverfile: coverFileName, }); err != nil { log.Error("[createAppResourcesRoute] UpsertDocument DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } c.Redirect(http.StatusFound, "./") return } func (api *API) deleteDocument(c *gin.Context) { var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { log.Error("[deleteDocument] Invalid URI Bind") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } changed, err := api.DB.Queries.DeleteDocument(api.DB.Ctx, rDocID.DocumentID) if err != nil { log.Error("[deleteDocument] DeleteDocument DB Error") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } if changed == 0 { log.Error("[deleteDocument] DeleteDocument DB Error") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"}) return } c.Redirect(http.StatusFound, "../") } func (api *API) identifyDocument(c *gin.Context) { rUser, _ := c.Get("AuthorizedUser") var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { log.Error("[identifyDocument] Invalid URI Bind") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } var rDocIdentify requestDocumentIdentify if err := c.ShouldBind(&rDocIdentify); err != nil { log.Error("[identifyDocument] Invalid Form Bind") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } // Disallow Empty Strings if rDocIdentify.Title != nil && strings.TrimSpace(*rDocIdentify.Title) == "" { rDocIdentify.Title = nil } if rDocIdentify.Author != nil && strings.TrimSpace(*rDocIdentify.Author) == "" { rDocIdentify.Author = nil } if rDocIdentify.ISBN != nil && strings.TrimSpace(*rDocIdentify.ISBN) == "" { rDocIdentify.ISBN = nil } // Validate Values if rDocIdentify.ISBN == nil && rDocIdentify.Title == nil && rDocIdentify.Author == nil { log.Error("[identifyDocument] Invalid Form") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } // Template Variables templateVars := gin.H{ "RelBase": "../../", } // Get Metadata metadataResults, err := metadata.SearchMetadata(metadata.GBOOK, metadata.MetadataInfo{ Title: rDocIdentify.Title, Author: rDocIdentify.Author, ISBN10: rDocIdentify.ISBN, ISBN13: rDocIdentify.ISBN, }) if err == nil && len(metadataResults) > 0 { firstResult := metadataResults[0] // Store First Metadata Result if _, err = api.DB.Queries.AddMetadata(api.DB.Ctx, database.AddMetadataParams{ DocumentID: rDocID.DocumentID, Title: firstResult.Title, Author: firstResult.Author, Description: firstResult.Description, Gbid: firstResult.ID, Olid: nil, Isbn10: firstResult.ISBN10, Isbn13: firstResult.ISBN13, }); err != nil { log.Error("[identifyDocument] AddMetadata DB Error:", err) } templateVars["Metadata"] = firstResult } else { log.Warn("[identifyDocument] Metadata Error") templateVars["MetadataError"] = "No Metadata Found" } document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{ UserID: rUser.(string), DocumentID: rDocID.DocumentID, }) if err != nil { log.Error("[identifyDocument] GetDocumentWithStats DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } statistics := gin.H{ "TotalTimeLeftSeconds": (document.Pages - document.Page) * document.SecondsPerPage, "WordsPerMinute": "N/A", } if document.Words != nil && *document.Words != 0 { statistics["WordsPerMinute"] = (*document.Words / document.Pages * document.ReadPages) / (document.TotalTimeSeconds / 60.0) } templateVars["Data"] = document templateVars["Statistics"] = statistics c.HTML(http.StatusOK, "document", templateVars) } func (api *API) editSettings(c *gin.Context) { rUser, _ := c.Get("AuthorizedUser") var rUserSettings requestSettingsEdit if err := c.ShouldBind(&rUserSettings); err != nil { log.Error("[editSettings] Invalid Form Bind") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } // Validate Something Exists if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.TimeOffset == nil { log.Error("[editSettings] Missing Form Values") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } templateVars := gin.H{ "User": rUser, } newUserSettings := database.UpdateUserParams{ UserID: rUser.(string), } // Set New Password if rUserSettings.Password != nil && rUserSettings.NewPassword != nil { password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password))) authorized := api.authorizeCredentials(rUser.(string), password) if authorized == true { password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword))) hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams) if err != nil { templateVars["PasswordErrorMessage"] = "Unknown Error" } else { templateVars["PasswordMessage"] = "Password Updated" newUserSettings.Password = &hashedPassword } } else { templateVars["PasswordErrorMessage"] = "Invalid Password" } } // Set Time Offset if rUserSettings.TimeOffset != nil { templateVars["TimeOffsetMessage"] = "Time Offset Updated" newUserSettings.TimeOffset = rUserSettings.TimeOffset } // Update User _, err := api.DB.Queries.UpdateUser(api.DB.Ctx, newUserSettings) if err != nil { log.Error("[editSettings] UpdateUser DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } // Get User user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string)) if err != nil { log.Error("[editSettings] GetUser DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } // Get Devices devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string)) if err != nil { log.Error("[editSettings] GetDevices DB Error:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) return } templateVars["Data"] = gin.H{ "Settings": gin.H{ "TimeOffset": *user.TimeOffset, }, "Devices": devices, } c.HTML(http.StatusOK, "settings", templateVars) } func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStatsRow) error { // Do Transaction tx, err := api.DB.DB.Begin() if err != nil { log.Error("[getDocumentsWordCount] Transaction Begin DB Error:", err) return err } // Defer & Start Transaction defer tx.Rollback() qtx := api.DB.Queries.WithTx(tx) for _, item := range documents { if item.Words == nil && item.Filepath != nil { filePath := filepath.Join(api.Config.DataPath, "documents", *item.Filepath) wordCount, err := metadata.GetWordCount(filePath) if err != nil { log.Warn("[getDocumentsWordCount] Word Count Error - ", err) } else { if _, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{ ID: item.ID, Words: &wordCount, }); err != nil { log.Error("[getDocumentsWordCount] UpsertDocument DB Error - ", err) return err } } } } // Commit Transaction if err := tx.Commit(); err != nil { log.Error("[getDocumentsWordCount] Transaction Commit DB Error:", err) return err } return nil } func bindQueryParams(c *gin.Context) queryParams { var qParams queryParams c.BindQuery(&qParams) if qParams.Limit == nil { var defaultValue int64 = 50 qParams.Limit = &defaultValue } else if *qParams.Limit < 0 { var zeroValue int64 = 0 qParams.Limit = &zeroValue } if qParams.Page == nil || *qParams.Page < 1 { var oneValue int64 = 0 qParams.Page = &oneValue } return qParams }