AnthoLume/api/ko-routes.go
Evan Reichard 015ca30ac5
All checks were successful
continuous-integration/drone/push Build is passing
feat(auth): add auth hash (allows purging sessions & more)
2024-01-28 11:21:06 -05:00

661 lines
17 KiB
Go

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"
"reichard.io/antholume/utils"
)
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
}
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
log.Error("Failed to generate user token: ", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
ID: rUser.Username,
Pass: &hashedPassword,
AuthHash: fmt.Sprintf("%x", rawAuthHash),
})
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
}