AnthoLume/api/app-routes-new.go
2025-08-30 20:53:09 -04:00

514 lines
17 KiB
Go

package api
import (
"cmp"
"crypto/md5"
"fmt"
"math"
"net/http"
"sort"
"strings"
"time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
"reichard.io/antholume/pkg/formatters"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/pkg/utils"
"reichard.io/antholume/search"
"reichard.io/antholume/web/components/stats"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages"
)
func (api *API) appGetHome(c *gin.Context) {
_, auth := api.getBaseTemplateVars("home", c)
start := time.Now()
dailyStats, err := api.db.Queries.GetDailyReadStats(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get daily read stats")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get daily read stats: %s", err))
return
}
log.Debug("GetDailyReadStats DB Performance: ", time.Since(start))
start = time.Now()
databaseInfo, err := api.db.Queries.GetDatabaseInfo(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get database info")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get database info: %s", err))
return
}
log.Debug("GetDatabaseInfo DB Performance: ", time.Since(start))
start = time.Now()
streaks, err := api.db.Queries.GetUserStreaks(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get user streaks")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user streaks: %s", err))
return
}
log.Debug("GetUserStreaks DB Performance: ", time.Since(start))
start = time.Now()
userStatistics, err := api.db.Queries.GetUserStatistics(c)
if err != nil {
log.WithError(err).Error("failed to get user statistics")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user statistics: %s", err))
return
}
log.Debug("GetUserStatistics DB Performance: ", time.Since(start))
api.renderPage(c, &pages.Home{
Leaderboard: arrangeUserStatistic(userStatistics),
Streaks: streaks,
DailyStats: dailyStats,
RecordInfo: &databaseInfo,
})
}
func (api *API) appGetDocuments(c *gin.Context) {
qParams, err := bindQueryParams(c, 9)
if err != nil {
log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return
}
var query *string
if qParams.Search != nil && *qParams.Search != "" {
search := "%" + *qParams.Search + "%"
query = &search
}
_, auth := api.getBaseTemplateVars("documents", c)
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: query,
Deleted: ptr.Of(false),
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
if err != nil {
log.WithError(err).Error("failed to get documents with stats")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get documents with stats: %s", err))
return
}
length, err := api.db.Queries.GetDocumentsSize(c, query)
if err != nil {
log.WithError(err).Error("failed to get document sizes")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document sizes: %s", err))
return
}
if err = api.getDocumentsWordCount(c, documents); err != nil {
log.WithError(err).Error("failed to get word counts")
}
totalPages := int64(math.Ceil(float64(length) / float64(*qParams.Limit)))
nextPage := *qParams.Page + 1
previousPage := *qParams.Page - 1
api.renderPage(c, pages.Documents{
Data: sliceutils.Map(documents, convertDBDocToUI),
Previous: utils.Ternary(previousPage >= 0, int(previousPage), 0),
Next: utils.Ternary(nextPage <= totalPages, int(nextPage), 0),
Limit: int(ptr.Deref(qParams.Limit)),
})
}
func (api *API) appGetDocument(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.WithError(err).Error("failed to bind URI")
appErrorPage(c, http.StatusNotFound, "Invalid document")
return
}
_, auth := api.getBaseTemplateVars("document", c)
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get document")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
return
}
api.renderPage(c, &pages.Document{Data: convertDBDocToUI(*document)})
}
func (api *API) appGetActivity(c *gin.Context) {
qParams, err := bindQueryParams(c, 15)
if err != nil {
log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return
}
_, auth := api.getBaseTemplateVars("activity", c)
activity, err := api.db.Queries.GetActivity(c, database.GetActivityParams{
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
DocFilter: qParams.Document != nil,
DocumentID: ptr.Deref(qParams.Document),
})
if err != nil {
log.WithError(err).Error("failed to get activity")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get activity: %s", err))
return
}
api.renderPage(c, &pages.Activity{Data: sliceutils.Map(activity, convertDBActivityToUI)})
}
func (api *API) appGetProgress(c *gin.Context) {
qParams, err := bindQueryParams(c, 15)
if err != nil {
log.WithError(err).Error("failed to bind query params")
appErrorPage(c, http.StatusBadRequest, fmt.Sprintf("failed to bind query params: %s", err))
return
}
_, auth := api.getBaseTemplateVars("progress", c)
progress, err := api.db.Queries.GetProgress(c, database.GetProgressParams{
UserID: auth.UserName,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
DocFilter: qParams.Document != nil,
DocumentID: ptr.Deref(qParams.Document),
})
if err != nil {
log.WithError(err).Error("failed to get progress")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get progress: %s", err))
return
}
api.renderPage(c, &pages.Progress{Data: sliceutils.Map(progress, convertDBProgressToUI)})
}
func (api *API) appIdentifyDocumentNew(c *gin.Context) {
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.WithError(err).Error("failed to bind URI")
appErrorPage(c, http.StatusNotFound, "Invalid document")
return
}
var rDocIdentify requestDocumentIdentify
if err := c.ShouldBind(&rDocIdentify); err != nil {
log.WithError(err).Error("failed to bind form")
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 or missing form values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Get Metadata
var searchResult *models.DocumentMetadata
var allNotifications []*models.Notification
metadataResults, err := metadata.SearchMetadata(metadata.SourceGoogleBooks, metadata.MetadataInfo{
Title: rDocIdentify.Title,
Author: rDocIdentify.Author,
ISBN10: rDocIdentify.ISBN,
ISBN13: rDocIdentify.ISBN,
})
if err != nil {
log.WithError(err).Error("failed to search metadata")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to search metadata: %s", err))
return
} else if firstResult, found := sliceutils.First(metadataResults); found {
searchResult = convertMetaToUI(firstResult)
// Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
DocumentID: rDocID.DocumentID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.SourceID,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.WithError(err).Error("failed to add metadata")
}
} else {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "No Metadata Found",
})
}
// Get Auth
_, auth := api.getBaseTemplateVars("document", c)
document, err := api.db.GetDocument(c, rDocID.DocumentID, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get document")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get document: %s", err))
return
}
api.renderPage(c, &pages.Document{
Data: convertDBDocToUI(*document),
Search: searchResult,
}, allNotifications...)
}
// Tabs:
// - General (Import, Backup & Restore, Version (githash?), Stats?)
// - Users
// - Metadata
func (api *API) appGetSearch(c *gin.Context) {
var sParams searchParams
if err := c.BindQuery(&sParams); err != nil {
log.WithError(err).Error("failed to bind form")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
// Only Handle Query
var searchResults []models.SearchResult
var searchError string
if sParams.Query != nil && sParams.Source != nil {
results, err := search.SearchBook(*sParams.Query, *sParams.Source)
if err != nil {
log.WithError(err).Error("failed to search book")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
return
}
searchResults = sliceutils.Map(results, convertSearchToUI)
} else if sParams.Query != nil || sParams.Source != nil {
searchError = "Invailid Query"
}
api.renderPage(c, &pages.Search{
Results: searchResults,
Source: ptr.Deref(sParams.Source),
Query: ptr.Deref(sParams.Query),
Error: searchError,
})
}
func (api *API) appGetSettings(c *gin.Context) {
_, auth := api.getBaseTemplateVars("settings", c)
user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
return
}
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get devices")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
return
}
api.renderPage(c, &pages.Settings{
Timezone: ptr.Deref(user.Timezone),
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
})
}
func (api *API) appEditSettings(c *gin.Context) {
var rUserSettings requestSettingsEdit
if err := c.ShouldBind(&rUserSettings); err != nil {
log.WithError(err).Error("failed to bind form")
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("invalid or missing form values")
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
_, auth := api.getBaseTemplateVars("settings", c)
newUserSettings := database.UpdateUserParams{
UserID: auth.UserName,
Admin: auth.IsAdmin,
}
// Set New Password
var allNotifications []*models.Notification
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
if _, err := api.authorizeCredentials(c, auth.UserName, password); err != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "Invalid Password",
})
} else {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeError,
Content: "Unknown Error",
})
} else {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeSuccess,
Content: "Password Updated",
})
newUserSettings.Password = &hashedPassword
}
}
}
// Set Time Offset
if rUserSettings.Timezone != nil {
allNotifications = append(allNotifications, &models.Notification{
Type: models.NotificationTypeSuccess,
Content: "Time Offset Updated",
})
newUserSettings.Timezone = rUserSettings.Timezone
}
// Update User
_, err := api.db.Queries.UpdateUser(c, newUserSettings)
if err != nil {
log.WithError(err).Error("failed to update user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to update user: %s", err))
return
}
// Get User
user, err := api.db.Queries.GetUser(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get user")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get user: %s", err))
return
}
// Get Devices
devices, err := api.db.Queries.GetDevices(c, auth.UserName)
if err != nil {
log.WithError(err).Error("failed to get devices")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to get devices: %s", err))
return
}
api.renderPage(c, &pages.Settings{
Devices: sliceutils.Map(devices, convertDBDeviceToUI),
Timezone: ptr.Deref(user.Timezone),
}, allNotifications...)
}
func (api *API) renderPage(c *gin.Context, page pages.Page, notifications ...*models.Notification) {
// Get Authentication Data
auth, err := getAuthData(c)
if err != nil {
log.WithError(err).Error("failed to acquire auth data")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to acquire auth data: %s", err))
return
}
// Generate Page
pageNode, err := page.Generate(models.PageContext{
UserInfo: &models.UserInfo{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
ServerInfo: &models.ServerInfo{
RegistrationEnabled: api.cfg.RegistrationEnabled,
SearchEnabled: api.cfg.SearchEnabled,
Version: api.cfg.Version,
},
Notifications: notifications,
})
if err != nil {
log.WithError(err).Error("failed to generate page")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to generate page: %s", err))
return
}
// Render Page
err = pageNode.Render(c.Writer)
if err != nil {
log.WithError(err).Error("failed to render page")
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("failed to render page: %s", err))
return
}
}
func sortItem[T cmp.Ordered](
data []database.GetUserStatisticsRow,
accessor func(s database.GetUserStatisticsRow) T,
formatter func(s T) string,
) []stats.LeaderboardItem {
sort.SliceStable(data, func(i, j int) bool {
return accessor(data[i]) > accessor(data[j])
})
var items []stats.LeaderboardItem
for _, s := range data {
items = append(items, stats.LeaderboardItem{
UserID: s.UserID,
Value: formatter(accessor(s)),
})
}
return items
}
func arrangeUserStatistic(data []database.GetUserStatisticsRow) []stats.LeaderboardData {
wpmFormatter := func(v float64) string { return fmt.Sprintf("%.2f WPM", v) }
return []stats.LeaderboardData{
{
Name: "WPM",
All: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.TotalWpm }, wpmFormatter),
Year: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.YearlyWpm }, wpmFormatter),
Month: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.MonthlyWpm }, wpmFormatter),
Week: sortItem(data, func(r database.GetUserStatisticsRow) float64 { return r.WeeklyWpm }, wpmFormatter),
},
{
Name: "Words",
All: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.TotalWordsRead }, formatters.FormatNumber),
Year: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.YearlyWordsRead }, formatters.FormatNumber),
Month: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.MonthlyWordsRead }, formatters.FormatNumber),
Week: sortItem(data, func(r database.GetUserStatisticsRow) int64 { return r.WeeklyWordsRead }, formatters.FormatNumber),
},
{
Name: "Duration",
All: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
return time.Duration(r.TotalSeconds) * time.Second
}, formatters.FormatDuration),
Year: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
return time.Duration(r.YearlySeconds) * time.Second
}, formatters.FormatDuration),
Month: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
return time.Duration(r.MonthlySeconds) * time.Second
}, formatters.FormatDuration),
Week: sortItem(data, func(r database.GetUserStatisticsRow) time.Duration {
return time.Duration(r.WeeklySeconds) * time.Second
}, formatters.FormatDuration),
},
}
}