1126 lines
33 KiB
Go
1126 lines
33 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"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/api/renderer"
|
|
"reichard.io/antholume/database"
|
|
"reichard.io/antholume/metadata"
|
|
"reichard.io/antholume/ngtemplates/common"
|
|
"reichard.io/antholume/ngtemplates/pages"
|
|
"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) appGetDocuments(c *gin.Context) {
|
|
settings, auth := api.getBaseTemplateVarsNew(common.RouteDocuments, c)
|
|
qParams := bindQueryParams(c, 9)
|
|
|
|
var query *string
|
|
if qParams.Search != nil && *qParams.Search != "" {
|
|
search := "%" + *qParams.Search + "%"
|
|
query = &search
|
|
}
|
|
|
|
documents, err := api.db.Queries.GetDocumentsWithStats(api.db.Ctx, database.GetDocumentsWithStatsParams{
|
|
UserID: auth.UserName,
|
|
Query: query,
|
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
|
Limit: *qParams.Limit,
|
|
})
|
|
if err != nil {
|
|
log.Error("GetDocumentsWithStats DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
|
|
return
|
|
}
|
|
|
|
length, err := api.db.Queries.GetDocumentsSize(api.db.Ctx, query)
|
|
if err != nil {
|
|
log.Error("GetDocumentsSize DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsSize DB Error: %v", err))
|
|
return
|
|
}
|
|
|
|
if err = api.getDocumentsWordCount(documents); err != nil {
|
|
log.Error("Unable to Get Word Counts: ", err)
|
|
}
|
|
|
|
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
|
|
nextPage := *qParams.Page + 1
|
|
previousPage := *qParams.Page - 1
|
|
|
|
r := renderer.New(c.Request.Context(), http.StatusOK, pages.Documents(
|
|
settings,
|
|
documents,
|
|
nextPage,
|
|
previousPage,
|
|
totalPages,
|
|
*qParams.Limit,
|
|
))
|
|
c.Render(http.StatusOK, r)
|
|
}
|
|
|
|
func (api *API) appGetDocument(c *gin.Context) {
|
|
templateVars, auth := api.getBaseTemplateVars("document", c)
|
|
|
|
var rDocID requestDocumentID
|
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
|
log.Error("Invalid URI Bind")
|
|
appErrorPage(c, http.StatusNotFound, "Invalid document")
|
|
return
|
|
}
|
|
|
|
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{
|
|
UserID: auth.UserName,
|
|
DocumentID: rDocID.DocumentID,
|
|
})
|
|
if err != nil {
|
|
log.Error("GetDocumentWithStats DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentsWithStats DB Error: %v", err))
|
|
return
|
|
}
|
|
|
|
templateVars["Data"] = document
|
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
|
|
|
c.HTML(http.StatusOK, "page/document", templateVars)
|
|
}
|
|
|
|
func (api *API) appGetProgress(c *gin.Context) {
|
|
templateVars, auth := api.getBaseTemplateVars("progress", c)
|
|
|
|
qParams := bindQueryParams(c, 15)
|
|
|
|
progressFilter := database.GetProgressParams{
|
|
UserID: auth.UserName,
|
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
|
Limit: *qParams.Limit,
|
|
}
|
|
|
|
if qParams.Document != nil {
|
|
progressFilter.DocFilter = true
|
|
progressFilter.DocumentID = *qParams.Document
|
|
}
|
|
|
|
progress, err := api.db.Queries.GetProgress(api.db.Ctx, progressFilter)
|
|
if err != nil {
|
|
log.Error("GetProgress DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
|
return
|
|
}
|
|
|
|
templateVars["Data"] = progress
|
|
|
|
c.HTML(http.StatusOK, "page/progress", templateVars)
|
|
}
|
|
|
|
func (api *API) appGetActivity(c *gin.Context) {
|
|
settings, auth := api.getBaseTemplateVarsNew(common.RouteActivity, c)
|
|
qParams := bindQueryParams(c, 15)
|
|
|
|
activityFilter := database.GetActivityParams{
|
|
UserID: auth.UserName,
|
|
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("GetActivity DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetActivity DB Error: %v", err))
|
|
return
|
|
}
|
|
|
|
r := renderer.New(c.Request.Context(), http.StatusOK, pages.Activity(
|
|
settings,
|
|
activity,
|
|
))
|
|
c.Render(http.StatusOK, r)
|
|
}
|
|
|
|
func (api *API) appGetHome(c *gin.Context) {
|
|
settings, auth := api.getBaseTemplateVarsNew(common.RouteHome, c)
|
|
|
|
start := time.Now()
|
|
graphData, err := api.db.Queries.GetDailyReadStats(api.db.Ctx, auth.UserName)
|
|
if err != nil {
|
|
log.Error("GetDailyReadStats DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDailyReadStats DB Error: %v", err))
|
|
return
|
|
}
|
|
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
|
|
|
|
start = time.Now()
|
|
databaseInfo, err := api.db.Queries.GetDatabaseInfo(api.db.Ctx, auth.UserName)
|
|
if err != nil {
|
|
log.Error("GetDatabaseInfo DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDatabaseInfo DB Error: %v", err))
|
|
return
|
|
}
|
|
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
|
|
|
|
start = time.Now()
|
|
streaks, err := api.db.Queries.GetUserStreaks(api.db.Ctx, auth.UserName)
|
|
if err != nil {
|
|
log.Error("GetUserStreaks DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStreaks DB Error: %v", err))
|
|
return
|
|
}
|
|
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
|
|
|
|
start = time.Now()
|
|
userStatistics, err := api.db.Queries.GetUserStatistics(api.db.Ctx)
|
|
if err != nil {
|
|
log.Error("GetUserStatistics DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUserStatistics DB Error: %v", err))
|
|
return
|
|
}
|
|
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
|
|
|
|
r := renderer.New(c.Request.Context(), http.StatusOK, pages.Home(
|
|
settings,
|
|
getSVGGraphData(graphData, 800, 70),
|
|
streaks,
|
|
arrangeUserStatistics(userStatistics),
|
|
common.UserMetadata{
|
|
DocumentCount: int(databaseInfo.DocumentsSize),
|
|
ActivityCount: int(databaseInfo.ActivitySize),
|
|
ProgressCount: int(databaseInfo.ProgressSize),
|
|
DeviceCount: int(databaseInfo.DevicesSize),
|
|
},
|
|
))
|
|
c.Render(http.StatusOK, r)
|
|
}
|
|
|
|
func (api *API) appGetSettings(c *gin.Context) {
|
|
templateVars, auth := api.getBaseTemplateVars("settings", c)
|
|
|
|
user, err := api.db.Queries.GetUser(api.db.Ctx, 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(api.db.Ctx, 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)
|
|
}
|
|
|
|
// Tabs:
|
|
// - General (Import, Backup & Restore, Version (githash?), Stats?)
|
|
// - Users
|
|
// - Metadata
|
|
func (api *API) appGetSearch(c *gin.Context) {
|
|
templateVars, _ := api.getBaseTemplateVars("search", c)
|
|
|
|
var sParams searchParams
|
|
err := c.BindQuery(&sParams)
|
|
if err != nil {
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
|
|
return
|
|
}
|
|
|
|
// Only Handle Query
|
|
if sParams.Query != nil && sParams.Source != nil {
|
|
// Search
|
|
searchResults, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
|
if err != nil {
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
|
return
|
|
}
|
|
|
|
templateVars["Data"] = searchResults
|
|
templateVars["Source"] = *sParams.Source
|
|
} else if sParams.Query != nil || sParams.Source != nil {
|
|
templateVars["SearchErrorMessage"] = "Invalid Query"
|
|
}
|
|
|
|
c.HTML(http.StatusOK, "page/search", 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(api.db.Ctx, 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.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{
|
|
UserID: auth.UserName,
|
|
DocumentID: rDoc.DocumentID,
|
|
})
|
|
if err != nil {
|
|
log.Error("GetDocumentWithStats DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats 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(api.db.Ctx, 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(api.db.Ctx, *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(api.db.Ctx, 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 {
|
|
var coverDir string = 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(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("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(api.db.Ctx, 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) appIdentifyDocument(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 rDocIdentify requestDocumentIdentify
|
|
if err := c.ShouldBind(&rDocIdentify); err != nil {
|
|
log.Error("Invalid Form Bind")
|
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
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("Invalid Form")
|
|
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
|
|
return
|
|
}
|
|
|
|
// Get Template Variables
|
|
templateVars, auth := api.getBaseTemplateVars("document", c)
|
|
|
|
// Get Metadata
|
|
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_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("AddMetadata DB Error: ", err)
|
|
}
|
|
|
|
templateVars["Metadata"] = firstResult
|
|
} else {
|
|
log.Warn("Metadata Error")
|
|
templateVars["MetadataError"] = "No Metadata Found"
|
|
}
|
|
|
|
document, err := api.db.Queries.GetDocumentWithStats(api.db.Ctx, database.GetDocumentWithStatsParams{
|
|
UserID: auth.UserName,
|
|
DocumentID: rDocID.DocumentID,
|
|
})
|
|
if err != nil {
|
|
log.Error("GetDocumentWithStats DB Error: ", err)
|
|
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
|
|
return
|
|
}
|
|
|
|
templateVars["Data"] = document
|
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
|
|
|
c.HTML(http.StatusOK, "page/document", templateVars)
|
|
}
|
|
|
|
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, `
|
|
<div class="absolute top-0 left-0 w-full h-full z-50">
|
|
<div class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"></div>
|
|
<div id="stream-main" class="relative max-h-[95%] -translate-x-2/4 top-1/2 left-1/2 w-5/6">`)
|
|
defer stream.close(`</div></div>`)
|
|
|
|
// 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(api.db.Ctx, 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(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(api.db.Ctx, 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(api.db.Ctx, 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(api.db.Ctx, 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(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(api.db.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 (api *API) getBaseTemplateVarsNew(route common.Route, c *gin.Context) (common.Settings, authData) {
|
|
var auth authData
|
|
if data, _ := c.Get("Authorization"); data != nil {
|
|
auth = data.(authData)
|
|
}
|
|
|
|
return common.Settings{
|
|
Route: route,
|
|
User: auth.UserName,
|
|
IsAdmin: auth.IsAdmin,
|
|
SearchEnabled: api.cfg.SearchEnabled,
|
|
Version: api.cfg.Version,
|
|
}, 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) {
|
|
var errorHuman string = "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,
|
|
})
|
|
}
|
|
|
|
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) common.UserStatistics {
|
|
// Item Sorter
|
|
sortItem := func(userStatistics []database.GetUserStatisticsRow, key string, less func(i int, j int) bool) []common.UserStatisticEntry {
|
|
sortedData := append([]database.GetUserStatisticsRow(nil), userStatistics...)
|
|
sort.SliceStable(sortedData, less)
|
|
|
|
newData := make([]common.UserStatisticEntry, 0)
|
|
for _, item := range sortedData {
|
|
v := reflect.Indirect(reflect.ValueOf(item))
|
|
|
|
var value string
|
|
if strings.Contains(key, "Wpm") {
|
|
rawVal := v.FieldByName(key).Float()
|
|
value = fmt.Sprintf("%.2f WPM", rawVal)
|
|
} else if strings.Contains(key, "Seconds") {
|
|
rawVal := v.FieldByName(key).Int()
|
|
value = niceSeconds(rawVal)
|
|
} else if strings.Contains(key, "Words") {
|
|
rawVal := v.FieldByName(key).Int()
|
|
value = niceNumbers(rawVal)
|
|
}
|
|
|
|
newData = append(newData, common.UserStatisticEntry{
|
|
UserID: item.UserID,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
return newData
|
|
}
|
|
|
|
return common.UserStatistics{
|
|
WPM: map[string][]common.UserStatisticEntry{
|
|
"All": sortItem(userStatistics, "TotalWpm", func(i, j int) bool {
|
|
return userStatistics[i].TotalWpm > userStatistics[j].TotalWpm
|
|
}),
|
|
"Year": sortItem(userStatistics, "YearlyWpm", func(i, j int) bool {
|
|
return userStatistics[i].YearlyWpm > userStatistics[j].YearlyWpm
|
|
}),
|
|
"Month": sortItem(userStatistics, "MonthlyWpm", func(i, j int) bool {
|
|
return userStatistics[i].MonthlyWpm > userStatistics[j].MonthlyWpm
|
|
}),
|
|
"Week": sortItem(userStatistics, "WeeklyWpm", func(i, j int) bool {
|
|
return userStatistics[i].WeeklyWpm > userStatistics[j].WeeklyWpm
|
|
}),
|
|
},
|
|
Duration: map[string][]common.UserStatisticEntry{
|
|
"All": sortItem(userStatistics, "TotalSeconds", func(i, j int) bool {
|
|
return userStatistics[i].TotalSeconds > userStatistics[j].TotalSeconds
|
|
}),
|
|
"Year": sortItem(userStatistics, "YearlySeconds", func(i, j int) bool {
|
|
return userStatistics[i].YearlySeconds > userStatistics[j].YearlySeconds
|
|
}),
|
|
"Month": sortItem(userStatistics, "MonthlySeconds", func(i, j int) bool {
|
|
return userStatistics[i].MonthlySeconds > userStatistics[j].MonthlySeconds
|
|
}),
|
|
"Week": sortItem(userStatistics, "WeeklySeconds", func(i, j int) bool {
|
|
return userStatistics[i].WeeklySeconds > userStatistics[j].WeeklySeconds
|
|
}),
|
|
},
|
|
Words: map[string][]common.UserStatisticEntry{
|
|
"All": sortItem(userStatistics, "TotalWordsRead", func(i, j int) bool {
|
|
return userStatistics[i].TotalWordsRead > userStatistics[j].TotalWordsRead
|
|
}),
|
|
"Year": sortItem(userStatistics, "YearlyWordsRead", func(i, j int) bool {
|
|
return userStatistics[i].YearlyWordsRead > userStatistics[j].YearlyWordsRead
|
|
}),
|
|
"Month": sortItem(userStatistics, "MonthlyWordsRead", func(i, j int) bool {
|
|
return userStatistics[i].MonthlyWordsRead > userStatistics[j].MonthlyWordsRead
|
|
}),
|
|
"Week": sortItem(userStatistics, "WeeklyWordsRead", func(i, j int) bool {
|
|
return userStatistics[i].WeeklyWordsRead > userStatistics[j].WeeklyWordsRead
|
|
}),
|
|
},
|
|
}
|
|
}
|