package api import ( "context" "crypto/md5" "database/sql" "fmt" "io" "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/antholume/database" "reichard.io/antholume/metadata" "reichard.io/antholume/search" ) type backupType string const ( backupCovers backupType = "COVERS" backupDocuments backupType = "DOCUMENTS" ) type queryParams struct { Page *int64 `form:"page"` Limit *int64 `form:"limit"` Search *string `form:"search"` Document *string `form:"document"` } type searchParams struct { Query *string `form:"query"` Source *search.Source `form:"source"` } type requestDocumentUpload struct { DocumentFile *multipart.FileHeader `form:"document_file"` } 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"` Timezone *string `form:"timezone"` } type requestDocumentAdd struct { ID string `form:"id"` Title *string `form:"title"` Author *string `form:"author"` Source search.Source `form:"source"` } func (api *API) appWebManifest(c *gin.Context) { c.Header("Content-Type", "application/manifest+json") c.FileFromFS("assets/manifest.json", http.FS(api.assets)) } func (api *API) appServiceWorker(c *gin.Context) { c.FileFromFS("assets/sw.js", http.FS(api.assets)) } func (api *API) appFaviconIcon(c *gin.Context) { c.FileFromFS("assets/icons/favicon.ico", http.FS(api.assets)) } func (api *API) appLocalDocuments(c *gin.Context) { c.FileFromFS("assets/local/index.htm", http.FS(api.assets)) } func (api *API) appDocumentReader(c *gin.Context) { c.FileFromFS("assets/reader/index.htm", http.FS(api.assets)) } func (api *API) appGetSettings(c *gin.Context) { templateVars, auth := api.getBaseTemplateVars("settings", c) user, err := api.db.Queries.GetUser(c, auth.UserName) if err != nil { log.Error("GetUser DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err)) return } devices, err := api.db.Queries.GetDevices(c, auth.UserName) if err != nil { log.Error("GetDevices DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err)) return } templateVars["Data"] = gin.H{ "Timezone": *user.Timezone, "Devices": devices, } c.HTML(http.StatusOK, "page/settings", templateVars) } func (api *API) appGetLogin(c *gin.Context) { templateVars, _ := api.getBaseTemplateVars("login", c) templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled c.HTML(http.StatusOK, "page/login", templateVars) } func (api *API) appGetRegister(c *gin.Context) { if !api.cfg.RegistrationEnabled { c.Redirect(http.StatusFound, "/login") return } templateVars, _ := api.getBaseTemplateVars("login", c) templateVars["RegistrationEnabled"] = api.cfg.RegistrationEnabled templateVars["Register"] = true c.HTML(http.StatusOK, "page/login", templateVars) } func (api *API) appGetDocumentProgress(c *gin.Context) { var auth authData if data, _ := c.Get("Authorization"); data != nil { auth = data.(authData) } var rDoc requestDocumentID if err := c.ShouldBindUri(&rDoc); err != nil { log.Error("Invalid URI Bind") appErrorPage(c, http.StatusNotFound, "Invalid document") return } progress, err := api.db.Queries.GetDocumentProgress(c, database.GetDocumentProgressParams{ DocumentID: rDoc.DocumentID, UserID: auth.UserName, }) if err != nil && err != sql.ErrNoRows { log.Error("GetDocumentProgress DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentProgress DB Error: %v", err)) return } document, err := api.db.GetDocument(c, rDoc.DocumentID, auth.UserName) if err != nil { log.Error("GetDocument DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err)) return } c.JSON(http.StatusOK, gin.H{ "id": document.ID, "title": document.Title, "author": document.Author, "words": document.Words, "progress": progress.Progress, "percentage": document.Percentage, }) } func (api *API) appGetDevices(c *gin.Context) { var auth authData if data, _ := c.Get("Authorization"); data != nil { auth = data.(authData) } devices, err := api.db.Queries.GetDevices(c, auth.UserName) if err != nil && err != sql.ErrNoRows { log.Error("GetDevices DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err)) return } c.JSON(http.StatusOK, devices) } func (api *API) appUploadNewDocument(c *gin.Context) { var rDocUpload requestDocumentUpload if err := c.ShouldBind(&rDocUpload); err != nil { log.Error("Invalid Form Bind") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") return } if rDocUpload.DocumentFile == nil { c.Redirect(http.StatusFound, "./documents") return } // Create Temp File tempFile, err := os.CreateTemp("", "book") if err != nil { log.Warn("Temp File Create Error: ", err) appErrorPage(c, http.StatusInternalServerError, "Unable to create temp file") return } defer os.Remove(tempFile.Name()) defer tempFile.Close() // Save Temp File err = c.SaveUploadedFile(rDocUpload.DocumentFile, tempFile.Name()) if err != nil { log.Error("File Error: ", err) appErrorPage(c, http.StatusInternalServerError, "Unable to save file") return } // Get Metadata metadataInfo, err := metadata.GetMetadata(tempFile.Name()) if err != nil { log.Errorf("unable to acquire metadata: %v", err) appErrorPage(c, http.StatusInternalServerError, "Unable to acquire metadata") return } // Check Already Exists _, err = api.db.Queries.GetDocument(c, *metadataInfo.PartialMD5) if err == nil { log.Warnf("document already exists: %s", *metadataInfo.PartialMD5) c.Redirect(http.StatusFound, fmt.Sprintf("./documents/%s", *metadataInfo.PartialMD5)) } // Derive & Sanitize File Name fileName := deriveBaseFileName(metadataInfo) basePath := filepath.Join(api.cfg.DataPath, "documents") safePath := filepath.Join(basePath, fileName) // Open Destination File destFile, err := os.Create(safePath) if err != nil { log.Errorf("unable to open destination file: %v", err) appErrorPage(c, http.StatusInternalServerError, "Unable to open destination file") return } defer destFile.Close() // Copy File if _, err = io.Copy(destFile, tempFile); err != nil { log.Errorf("unable to save file: %v", err) appErrorPage(c, http.StatusInternalServerError, "Unable to save file") return } // Upsert Document if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{ ID: *metadataInfo.PartialMD5, Title: metadataInfo.Title, Author: metadataInfo.Author, Description: metadataInfo.Description, Md5: metadataInfo.MD5, Words: metadataInfo.WordCount, Filepath: &fileName, Basepath: &basePath, }); err != nil { log.Errorf("UpsertDocument DB Error: %v", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) return } c.Redirect(http.StatusFound, fmt.Sprintf("./documents/%s", *metadataInfo.PartialMD5)) } func (api *API) appEditDocument(c *gin.Context) { var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { log.Error("Invalid URI Bind") appErrorPage(c, http.StatusNotFound, "Invalid document") return } var rDocEdit requestDocumentEdit if err := c.ShouldBind(&rDocEdit); err != nil { log.Error("Invalid Form Bind") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") 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("Missing Form Values") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") 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("File Error") appErrorPage(c, http.StatusInternalServerError, "Unable to open file") return } fileMime, err := mimetype.DetectReader(uploadedFile) if err != nil { log.Error("MIME Error") appErrorPage(c, http.StatusInternalServerError, "Unable to detect filetype") return } fileExtension := fileMime.Extension() // Validate Extension if !slices.Contains([]string{".jpg", ".png"}, fileExtension) { log.Error("Invalid FileType: ", fileExtension) appErrorPage(c, http.StatusBadRequest, "Invalid filetype") return } // Generate Storage Path fileName := fmt.Sprintf("%s%s", rDocID.DocumentID, fileExtension) safePath := filepath.Join(api.cfg.DataPath, "covers", fileName) // Save err = c.SaveUploadedFile(rDocEdit.CoverFile, safePath) if err != nil { log.Error("File Error: ", err) appErrorPage(c, http.StatusInternalServerError, "Unable to save file") return } coverFileName = &fileName } else if rDocEdit.CoverGBID != nil { coverDir := filepath.Join(api.cfg.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(c, 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("UpsertDocument DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err)) return } c.Redirect(http.StatusFound, "./") } func (api *API) appDeleteDocument(c *gin.Context) { var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { log.Error("Invalid URI Bind") appErrorPage(c, http.StatusNotFound, "Invalid document") return } changed, err := api.db.Queries.DeleteDocument(c, rDocID.DocumentID) if err != nil { log.Error("DeleteDocument DB Error") appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("DeleteDocument DB Error: %v", err)) return } if changed == 0 { log.Error("DeleteDocument DB Error") appErrorPage(c, http.StatusNotFound, "Invalid document") return } c.Redirect(http.StatusFound, "../") } func (api *API) appSaveNewDocument(c *gin.Context) { var rDocAdd requestDocumentAdd if err := c.ShouldBind(&rDocAdd); err != nil { log.Error("Invalid Form Bind") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") return } // Render Initial Template templateVars, _ := api.getBaseTemplateVars("search", c) c.HTML(http.StatusOK, "page/search", templateVars) // Create Streamer stream := api.newStreamer(c, `
`) defer stream.close(`
`) // Stream Helper Function sendDownloadMessage := func(msg string, args ...map[string]any) { // Merge Defaults & Overrides var templateVars = gin.H{ "Message": msg, "ButtonText": "Close", "ButtonHref": "./search", } if len(args) > 0 { for key := range args[0] { templateVars[key] = args[0][key] } } stream.send("component/download-progress", templateVars) } // Send Message sendDownloadMessage("Downloading document...", gin.H{"Progress": 1}) // Scaled Download Function lastTime := time.Now() downloadFunc := func(p float32) { nowTime := time.Now() if nowTime.Before(lastTime.Add(time.Millisecond * 500)) { return } scaledProgress := int((p * 95 / 100) + 2) sendDownloadMessage("Downloading document...", gin.H{"Progress": scaledProgress}) lastTime = nowTime } // Save Book tempFilePath, metadata, err := search.SaveBook(rDocAdd.ID, rDocAdd.Source, downloadFunc) if err != nil { log.Warn("Save Book Error: ", err) sendDownloadMessage("Unable to download file", gin.H{"Error": true}) return } // Send Message sendDownloadMessage("Saving document...", gin.H{"Progress": 98}) // Derive Author / Title docAuthor := "Unknown" if *metadata.Author != "" { docAuthor = *metadata.Author } else if *rDocAdd.Author != "" { docAuthor = *rDocAdd.Author } docTitle := "Unknown" if *metadata.Title != "" { docTitle = *metadata.Title } else if *rDocAdd.Title != "" { docTitle = *rDocAdd.Title } // Remove Slashes & Sanitize File Name fileName := fmt.Sprintf("%s - %s", docAuthor, docTitle) fileName = strings.ReplaceAll(fileName, "/", "") fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadata.PartialMD5, metadata.Type)) // Open Source File sourceFile, err := os.Open(tempFilePath) if err != nil { log.Error("Source File Error: ", err) sendDownloadMessage("Unable to open file", gin.H{"Error": true}) return } defer os.Remove(tempFilePath) defer sourceFile.Close() // Generate Storage Path & Open File basePath := filepath.Join(api.cfg.DataPath, "documents") safePath := filepath.Join(basePath, fileName) destFile, err := os.Create(safePath) if err != nil { log.Error("Dest File Error: ", err) sendDownloadMessage("Unable to create file", gin.H{"Error": true}) return } defer destFile.Close() // Copy File if _, err = io.Copy(destFile, sourceFile); err != nil { log.Error("Copy Temp File Error: ", err) sendDownloadMessage("Unable to save file", gin.H{"Error": true}) return } // Send Message sendDownloadMessage("Saving to database...", gin.H{"Progress": 99}) // Upsert Document if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{ ID: *metadata.PartialMD5, Title: &docTitle, Author: &docAuthor, Md5: metadata.MD5, Words: metadata.WordCount, Filepath: &fileName, Basepath: &basePath, }); err != nil { log.Error("UpsertDocument DB Error: ", err) sendDownloadMessage("Unable to save to database", gin.H{"Error": true}) return } // Send Message sendDownloadMessage("Download Success", gin.H{ "Progress": 100, "ButtonText": "Go to Book", "ButtonHref": fmt.Sprintf("./documents/%s", *metadata.PartialMD5), }) } func (api *API) appEditSettings(c *gin.Context) { var rUserSettings requestSettingsEdit if err := c.ShouldBind(&rUserSettings); err != nil { log.Error("Invalid Form Bind") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") return } // Validate Something Exists if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.Timezone == nil { log.Error("Missing Form Values") appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values") return } templateVars, auth := api.getBaseTemplateVars("settings", c) newUserSettings := database.UpdateUserParams{ UserID: auth.UserName, Admin: auth.IsAdmin, } // Set New Password if rUserSettings.Password != nil && rUserSettings.NewPassword != nil { password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password))) data := api.authorizeCredentials(c, auth.UserName, password) if data == nil { templateVars["PasswordErrorMessage"] = "Invalid Password" } else { 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 } } } // Set Time Offset if rUserSettings.Timezone != nil { templateVars["TimeOffsetMessage"] = "Time Offset Updated" newUserSettings.Timezone = rUserSettings.Timezone } // Update User _, err := api.db.Queries.UpdateUser(c, newUserSettings) if err != nil { log.Error("UpdateUser DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpdateUser DB Error: %v", err)) return } // Get User user, err := api.db.Queries.GetUser(c, auth.UserName) if err != nil { log.Error("GetUser DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUser DB Error: %v", err)) return } // Get Devices devices, err := api.db.Queries.GetDevices(c, auth.UserName) if err != nil { log.Error("GetDevices DB Error: ", err) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err)) return } templateVars["Data"] = gin.H{ "Timezone": *user.Timezone, "Devices": devices, } c.HTML(http.StatusOK, "page/settings", templateVars) } func (api *API) appDemoModeError(c *gin.Context) { appErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode") } func (api *API) getDocumentsWordCount(ctx context.Context, documents []database.GetDocumentsWithStatsRow) error { // Do Transaction tx, err := api.db.DB.Begin() if err != nil { log.Error("Transaction Begin DB Error: ", err) return err } // Defer & Start Transaction defer func() { if err := tx.Rollback(); err != nil { log.Error("DB Rollback Error:", err) } }() qtx := api.db.Queries.WithTx(tx) for _, item := range documents { if item.Words == nil && item.Filepath != nil { filePath := filepath.Join(api.cfg.DataPath, "documents", *item.Filepath) wordCount, err := metadata.GetWordCount(filePath) if err != nil { log.Warn("Word Count Error: ", err) } else { if _, err := qtx.UpsertDocument(ctx, database.UpsertDocumentParams{ ID: item.ID, Words: wordCount, }); err != nil { log.Error("UpsertDocument DB Error: ", err) return err } } } } // Commit Transaction if err := tx.Commit(); err != nil { log.Error("Transaction Commit DB Error: ", err) return err } return nil } func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, authData) { var auth authData if data, _ := c.Get("Authorization"); data != nil { auth = data.(authData) } return gin.H{ "Authorization": auth, "RouteName": routeName, "Config": gin.H{ "Version": api.cfg.Version, "SearchEnabled": api.cfg.SearchEnabled, "RegistrationEnabled": api.cfg.RegistrationEnabled, }, }, auth } func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { var qParams queryParams err := c.BindQuery(&qParams) if err != nil { appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err)) return qParams } if qParams.Limit == nil { qParams.Limit = &defaultLimit } else if *qParams.Limit < 0 { var zeroValue int64 = 0 qParams.Limit = &zeroValue } if qParams.Page == nil || *qParams.Page < 1 { var oneValue int64 = 1 qParams.Page = &oneValue } return qParams } func appErrorPage(c *gin.Context, errorCode int, errorMessage string) { errorHuman := "We're not even sure what happened." switch errorCode { case http.StatusInternalServerError: errorHuman = "Server hiccup." case http.StatusNotFound: errorHuman = "Something's missing." case http.StatusBadRequest: errorHuman = "We didn't expect that." case http.StatusUnauthorized: errorHuman = "You're not allowed to do that." } c.HTML(errorCode, "page/error", gin.H{ "Status": errorCode, "Error": errorHuman, "Message": errorMessage, }) }