package api import ( "crypto/md5" "database/sql" "encoding/json" "fmt" "html" "io" "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" ) type activityItem struct { DocumentID string `json:"document"` StartTime int64 `json:"start_time"` Duration int64 `json:"duration"` Page int64 `json:"page"` Pages int64 `json:"pages"` } type requestActivity struct { DeviceID string `json:"device_id"` Device string `json:"device"` Activity []activityItem `json:"activity"` } type requestCheckActivitySync struct { DeviceID string `json:"device_id"` Device string `json:"device"` } type requestDocument struct { Documents []database.Document `json:"documents"` } type requestPosition struct { DocumentID string `json:"document"` Percentage float64 `json:"percentage"` Progress string `json:"progress"` Device string `json:"device"` DeviceID string `json:"device_id"` } type requestUser struct { Username string `json:"username"` Password string `json:"password"` } type requestCheckDocumentSync struct { DeviceID string `json:"device_id"` Device string `json:"device"` Have []string `json:"have"` } type responseCheckDocumentSync struct { WantFiles []string `json:"want_files"` WantMetadata []string `json:"want_metadata"` Give []database.Document `json:"give"` Delete []string `json:"deleted"` } type requestDocumentID struct { DocumentID string `uri:"document" binding:"required"` } func (api *API) koAuthorizeUser(c *gin.Context) { c.JSON(200, gin.H{ "authorized": "OK", }) } func (api *API) koCreateUser(c *gin.Context) { if !api.cfg.RegistrationEnabled { c.AbortWithStatus(http.StatusConflict) return } var rUser requestUser if err := c.ShouldBindJSON(&rUser); err != nil { log.Error("Invalid JSON Bind") apiErrorPage(c, http.StatusBadRequest, "Invalid User Data") return } if rUser.Username == "" || rUser.Password == "" { log.Error("Invalid User - Empty Username or Password") apiErrorPage(c, http.StatusBadRequest, "Invalid User Data") return } hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams) if err != nil { log.Error("Argon2 Hash Failure:", err) apiErrorPage(c, http.StatusBadRequest, "Unknown Error") return } rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{ ID: rUser.Username, Pass: &hashedPassword, }) if err != nil { log.Error("CreateUser DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Invalid User Data") return } // User Exists if rows == 0 { log.Error("User Already Exists:", rUser.Username) apiErrorPage(c, http.StatusBadRequest, "User Already Exists") return } c.JSON(http.StatusCreated, gin.H{ "username": rUser.Username, }) } func (api *API) koSetProgress(c *gin.Context) { var auth authData if data, _ := c.Get("Authorization"); data != nil { auth = data.(authData) } var rPosition requestPosition if err := c.ShouldBindJSON(&rPosition); err != nil { log.Error("Invalid JSON Bind") apiErrorPage(c, http.StatusBadRequest, "Invalid Progress Data") return } // Upsert Device if _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{ ID: rPosition.DeviceID, UserID: auth.UserName, DeviceName: rPosition.Device, LastSynced: time.Now().UTC().Format(time.RFC3339), }); err != nil { log.Error("UpsertDevice DB Error:", err) } // Upsert Document if _, err := api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ ID: rPosition.DocumentID, }); err != nil { log.Error("UpsertDocument DB Error:", err) } // Create or Replace Progress progress, err := api.db.Queries.UpdateProgress(api.db.Ctx, database.UpdateProgressParams{ Percentage: rPosition.Percentage, DocumentID: rPosition.DocumentID, DeviceID: rPosition.DeviceID, UserID: auth.UserName, Progress: rPosition.Progress, }) if err != nil { log.Error("UpdateProgress DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } c.JSON(http.StatusOK, gin.H{ "document": progress.DocumentID, "timestamp": progress.CreatedAt, }) } func (api *API) koGetProgress(c *gin.Context) { var auth authData if data, _ := c.Get("Authorization"); data != nil { auth = data.(authData) } var rDocID requestDocumentID if err := c.ShouldBindUri(&rDocID); err != nil { log.Error("Invalid URI Bind") apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } progress, err := api.db.Queries.GetDocumentProgress(api.db.Ctx, database.GetDocumentProgressParams{ DocumentID: rDocID.DocumentID, UserID: auth.UserName, }) if err == sql.ErrNoRows { // Not Found c.JSON(http.StatusOK, gin.H{}) return } else if err != nil { log.Error("GetDocumentProgress DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Document") return } c.JSON(http.StatusOK, gin.H{ "document": progress.DocumentID, "percentage": progress.Percentage, "progress": progress.Progress, "device": progress.DeviceName, "device_id": progress.DeviceID, }) } func (api *API) koAddActivities(c *gin.Context) { var auth authData if data, _ := c.Get("Authorization"); data != nil { auth = data.(authData) } var rActivity requestActivity if err := c.ShouldBindJSON(&rActivity); err != nil { log.Error("Invalid JSON Bind") apiErrorPage(c, http.StatusBadRequest, "Invalid Activity") return } // Do Transaction tx, err := api.db.DB.Begin() if err != nil { log.Error("Transaction Begin DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Unknown Error") return } // Derive Unique Documents allDocumentsMap := make(map[string]bool) for _, item := range rActivity.Activity { allDocumentsMap[item.DocumentID] = true } allDocuments := getKeys(allDocumentsMap) // Defer & Start Transaction defer tx.Rollback() qtx := api.db.Queries.WithTx(tx) // Upsert Documents for _, doc := range allDocuments { if _, err := qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ ID: doc, }); err != nil { log.Error("UpsertDocument DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Document") return } } // Upsert Device if _, err = qtx.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{ ID: rActivity.DeviceID, UserID: auth.UserName, DeviceName: rActivity.Device, LastSynced: time.Now().UTC().Format(time.RFC3339), }); err != nil { log.Error("UpsertDevice DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Device") return } // Add All Activity for _, item := range rActivity.Activity { if _, err := qtx.AddActivity(api.db.Ctx, database.AddActivityParams{ UserID: auth.UserName, DocumentID: item.DocumentID, DeviceID: rActivity.DeviceID, StartTime: time.Unix(int64(item.StartTime), 0).UTC().Format(time.RFC3339), Duration: int64(item.Duration), StartPercentage: float64(item.Page) / float64(item.Pages), EndPercentage: float64(item.Page+1) / float64(item.Pages), }); err != nil { log.Error("AddActivity DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Activity") return } } // Commit Transaction if err := tx.Commit(); err != nil { log.Error("Transaction Commit DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Unknown Error") return } c.JSON(http.StatusOK, gin.H{ "added": len(rActivity.Activity), }) } func (api *API) koCheckActivitySync(c *gin.Context) { var auth authData if data, _ := c.Get("Authorization"); data != nil { auth = data.(authData) } var rCheckActivity requestCheckActivitySync if err := c.ShouldBindJSON(&rCheckActivity); err != nil { log.Error("Invalid JSON Bind") apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } // Upsert Device if _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{ ID: rCheckActivity.DeviceID, UserID: auth.UserName, DeviceName: rCheckActivity.Device, LastSynced: time.Now().UTC().Format(time.RFC3339), }); err != nil { log.Error("UpsertDevice DB Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Device") return } // Get Last Device Activity lastActivity, err := api.db.Queries.GetLastActivity(api.db.Ctx, database.GetLastActivityParams{ UserID: auth.UserName, DeviceID: rCheckActivity.DeviceID, }) if err == sql.ErrNoRows { lastActivity = time.UnixMilli(0).Format(time.RFC3339) } else if err != nil { log.Error("GetLastActivity DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Unknown Error") return } // Parse Time parsedTime, err := time.Parse(time.RFC3339, lastActivity) if err != nil { log.Error("Time Parse Error:", err) apiErrorPage(c, http.StatusBadRequest, "Unknown Error") return } c.JSON(http.StatusOK, gin.H{ "last_sync": parsedTime.Unix(), }) } func (api *API) koAddDocuments(c *gin.Context) { var rNewDocs requestDocument if err := c.ShouldBindJSON(&rNewDocs); err != nil { log.Error("Invalid JSON Bind") apiErrorPage(c, http.StatusBadRequest, "Invalid Document(s)") return } // Do Transaction tx, err := api.db.DB.Begin() if err != nil { log.Error("Transaction Begin DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Unknown Error") return } // Defer & Start Transaction defer tx.Rollback() qtx := api.db.Queries.WithTx(tx) // Upsert Documents for _, doc := range rNewDocs.Documents { _, err := qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ ID: doc.ID, Title: api.sanitizeInput(doc.Title), Author: api.sanitizeInput(doc.Author), Series: api.sanitizeInput(doc.Series), SeriesIndex: doc.SeriesIndex, Lang: api.sanitizeInput(doc.Lang), Description: api.sanitizeInput(doc.Description), }) if err != nil { log.Error("UpsertDocument DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Document") return } } // Commit Transaction if err := tx.Commit(); err != nil { log.Error("Transaction Commit DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Unknown Error") return } c.JSON(http.StatusOK, gin.H{ "changed": len(rNewDocs.Documents), }) } func (api *API) koCheckDocumentsSync(c *gin.Context) { var auth authData if data, _ := c.Get("Authorization"); data != nil { auth = data.(authData) } var rCheckDocs requestCheckDocumentSync if err := c.ShouldBindJSON(&rCheckDocs); err != nil { log.Error("Invalid JSON Bind") apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } // Upsert Device _, err := api.db.Queries.UpsertDevice(api.db.Ctx, database.UpsertDeviceParams{ ID: rCheckDocs.DeviceID, UserID: auth.UserName, DeviceName: rCheckDocs.Device, LastSynced: time.Now().UTC().Format(time.RFC3339), }) if err != nil { log.Error("UpsertDevice DB Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Device") return } missingDocs := []database.Document{} deletedDocIDs := []string{} // Get Missing Documents missingDocs, err = api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have) if err != nil { log.Error("GetMissingDocuments DB Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } // Get Deleted Documents deletedDocIDs, err = api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have) if err != nil { log.Error("GetDeletedDocuments DB Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } // Get Wanted Documents jsonHaves, err := json.Marshal(rCheckDocs.Have) if err != nil { log.Error("JSON Marshal Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } wantedDocs, err := api.db.Queries.GetWantedDocuments(api.db.Ctx, string(jsonHaves)) if err != nil { log.Error("GetWantedDocuments DB Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } // Split Metadata & File Wants var wantedMetadataDocIDs []string var wantedFilesDocIDs []string for _, v := range wantedDocs { if v.WantMetadata { wantedMetadataDocIDs = append(wantedMetadataDocIDs, v.ID) } if v.WantFile { wantedFilesDocIDs = append(wantedFilesDocIDs, v.ID) } } rCheckDocSync := responseCheckDocumentSync{ Delete: []string{}, WantFiles: []string{}, WantMetadata: []string{}, Give: []database.Document{}, } // Ensure Empty Array if wantedMetadataDocIDs != nil { rCheckDocSync.WantMetadata = wantedMetadataDocIDs } if wantedFilesDocIDs != nil { rCheckDocSync.WantFiles = wantedFilesDocIDs } if missingDocs != nil { rCheckDocSync.Give = missingDocs } if deletedDocIDs != nil { rCheckDocSync.Delete = deletedDocIDs } c.JSON(http.StatusOK, rCheckDocSync) } func (api *API) koUploadExistingDocument(c *gin.Context) { var rDoc requestDocumentID if err := c.ShouldBindUri(&rDoc); err != nil { log.Error("Invalid URI Bind") apiErrorPage(c, http.StatusBadRequest, "Invalid Request") return } fileData, err := c.FormFile("file") if err != nil { log.Error("File Error:", err) apiErrorPage(c, http.StatusBadRequest, "File Error") return } // Validate Type & Derive Extension on MIME uploadedFile, err := fileData.Open() fileMime, err := mimetype.DetectReader(uploadedFile) fileExtension := fileMime.Extension() if !slices.Contains([]string{".epub", ".html"}, fileExtension) { log.Error("Invalid FileType:", fileExtension) apiErrorPage(c, http.StatusBadRequest, "Invalid Filetype") return } // Validate Document Exists in DB document, err := api.db.Queries.GetDocument(api.db.Ctx, rDoc.DocumentID) if err != nil { log.Error("GetDocument DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Unknown Document") return } // Derive Filename var fileName string if document.Author != nil { fileName = fileName + *document.Author } else { fileName = fileName + "Unknown" } if document.Title != nil { fileName = fileName + " - " + *document.Title } else { fileName = fileName + " - Unknown" } // Remove Slashes fileName = strings.ReplaceAll(fileName, "/", "") // Derive & Sanitize File Name fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, document.ID, fileExtension)) // Generate Storage Path safePath := filepath.Join(api.cfg.DataPath, "documents", fileName) // Save & Prevent Overwrites _, err = os.Stat(safePath) if os.IsNotExist(err) { err = c.SaveUploadedFile(fileData, safePath) if err != nil { log.Error("Save Failure:", err) apiErrorPage(c, http.StatusBadRequest, "File Error") return } } // Get MD5 Hash fileHash, err := getFileMD5(safePath) if err != nil { log.Error("Hash Failure:", err) apiErrorPage(c, http.StatusBadRequest, "File Error") return } // Get Word Count wordCount, err := metadata.GetWordCount(safePath) if err != nil { log.Error("Word Count Failure:", err) apiErrorPage(c, http.StatusBadRequest, "File Error") return } // Upsert Document if _, err = api.db.Queries.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{ ID: document.ID, Md5: fileHash, Filepath: &fileName, Words: &wordCount, }); err != nil { log.Error("UpsertDocument DB Error:", err) apiErrorPage(c, http.StatusBadRequest, "Document Error") return } c.JSON(http.StatusOK, gin.H{ "status": "ok", }) } func (api *API) koDemoModeJSONError(c *gin.Context) { apiErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode") } func apiErrorPage(c *gin.Context, errorCode int, errorMessage string) { c.AbortWithStatusJSON(errorCode, gin.H{"error": errorMessage}) } func (api *API) sanitizeInput(val any) *string { switch v := val.(type) { case *string: if v != nil { newString := html.UnescapeString(htmlPolicy.Sanitize(string(*v))) return &newString } case string: if v != "" { newString := html.UnescapeString(htmlPolicy.Sanitize(string(v))) return &newString } } return nil } func getKeys[M ~map[K]V, K comparable, V any](m M) []K { r := make([]K, 0, len(m)) for k := range m { r = append(r, k) } return r } func getFileMD5(filePath string) (*string, error) { file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() hash := md5.New() _, err = io.Copy(hash, file) if err != nil { return nil, err } fileHash := fmt.Sprintf("%x", hash.Sum(nil)) return &fileHash, nil }