This commit is contained in:
2026-03-16 19:49:33 -04:00
parent 93707ff513
commit fd9afe86b0
22 changed files with 1188 additions and 224 deletions

View File

@@ -9,7 +9,7 @@ import (
"fmt"
"io"
"io/fs"
"net/http"
"mime/multipart"
"os"
"path/filepath"
"sort"
@@ -77,16 +77,30 @@ func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionReq
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Read the multipart form in a streaming way to support large files
reader := request.Body
form, err := reader.ReadForm(32 << 20) // 32MB for non-file fields (files are not stored in memory)
if err != nil {
return PostAdminAction400JSONResponse{Code: 400, Message: "Unable to parse form"}, nil
}
// Extract action from form
actionValues := form.Value["action"]
if len(actionValues) == 0 {
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing action"}, nil
}
action := actionValues[0]
// Handle different admin actions mirroring legacy appPerformAdminAction
switch request.Body.Action {
switch action {
case "METADATA_MATCH":
// This is a TODO in the legacy code as well
go func() {
// TODO: Implement metadata matching logic
log.Info("Metadata match action triggered (not yet implemented)")
}()
return PostAdminAction200ApplicationoctetStreamResponse{
Body: strings.NewReader("Metadata match started"),
return PostAdminAction200JSONResponse{
Message: "Metadata match started",
}, nil
case "CACHE_TABLES":
@@ -97,15 +111,15 @@ func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionReq
log.Error("Unable to cache temp tables: ", err)
}
}()
return PostAdminAction200ApplicationoctetStreamResponse{
Body: strings.NewReader("Cache tables operation started"),
return PostAdminAction200JSONResponse{
Message: "Cache tables operation started",
}, nil
case "BACKUP":
return s.handleBackupAction(ctx, request)
return s.handleBackupAction(ctx, request, form)
case "RESTORE":
return s.handleRestoreAction(ctx, request)
return s.handleRestoreAction(ctx, request, form)
default:
return PostAdminAction400JSONResponse{Code: 400, Message: "Invalid action"}, nil
@@ -113,59 +127,51 @@ func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionReq
}
// handleBackupAction handles the backup action, mirroring legacy createBackup logic
func (s *Server) handleBackupAction(ctx context.Context, request PostAdminActionRequestObject) (PostAdminActionResponseObject, error) {
func (s *Server) handleBackupAction(ctx context.Context, request PostAdminActionRequestObject, form *multipart.Form) (PostAdminActionResponseObject, error) {
// Extract backup_types from form
backupTypesValues := form.Value["backup_types"]
// Create a pipe for streaming the backup
pr, pw := io.Pipe()
go func() {
defer pw.Close()
var directories []string
if request.Body.BackupTypes != nil {
for _, item := range *request.Body.BackupTypes {
if item == "COVERS" {
directories = append(directories, "covers")
} else if item == "DOCUMENTS" {
directories = append(directories, "documents")
}
for _, val := range backupTypesValues {
if val == "COVERS" {
directories = append(directories, "covers")
} else if val == "DOCUMENTS" {
directories = append(directories, "documents")
}
}
log.Info("Starting backup for directories: ", directories)
err := s.createBackup(ctx, pw, directories)
if err != nil {
log.Error("Backup Error: ", err)
log.Error("Backup failed: ", err)
} else {
log.Info("Backup completed successfully")
}
}()
// Set Content-Length to 0 to enable chunked transfer encoding
// This allows streaming with unknown file size
return PostAdminAction200ApplicationoctetStreamResponse{
Body: pr,
Body: pr,
ContentLength: 0,
}, nil
}
// handleRestoreAction handles the restore action, mirroring legacy processRestoreFile logic
func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActionRequestObject) (PostAdminActionResponseObject, error) {
if request.Body == nil || request.Body.RestoreFile == nil {
func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActionRequestObject, form *multipart.Form) (PostAdminActionResponseObject, error) {
// Get the uploaded file from form
fileHeaders := form.File["restore_file"]
if len(fileHeaders) == 0 {
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing restore file"}, nil
}
// Read multipart form (similar to CreateDocument)
// Since the Body has the file, we need to extract it differently
// The request.Body.RestoreFile is of type openapi_types.File
// For now, let's access the raw request from context
r := ctx.Value("request").(*http.Request)
if r == nil {
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to get request"}, nil
}
// Parse multipart form from raw request
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
file, err := fileHeaders[0].Open()
if err != nil {
return PostAdminAction500JSONResponse{Code: 500, Message: "Failed to parse form"}, nil
}
// Get the uploaded file
file, _, err := r.FormFile("restore_file")
if err != nil {
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to get file from form"}, nil
return PostAdminAction400JSONResponse{Code: 400, Message: "Unable to open restore file"}, nil
}
defer file.Close()
@@ -180,17 +186,20 @@ func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActio
// Save uploaded file to temp
if _, err = io.Copy(tempFile, file); err != nil {
log.Error("Unable to save uploaded file: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save file"}, nil
}
// Get file info and validate ZIP
fileInfo, err := tempFile.Stat()
if err != nil {
log.Error("Unable to read temp file: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read file"}, nil
}
zipReader, err := zip.NewReader(tempFile, fileInfo.Size())
if err != nil {
log.Error("Unable to read zip: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read zip"}, nil
}
@@ -213,9 +222,11 @@ func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActio
}
// Create backup before restoring (mirroring legacy logic)
log.Info("Creating backup before restore...")
backupFilePath := filepath.Join(s.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
backupFile, err := os.Create(backupFilePath)
if err != nil {
log.Error("Unable to create backup file: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to create backup file"}, nil
}
defer backupFile.Close()
@@ -223,46 +234,55 @@ func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActio
w := bufio.NewWriter(backupFile)
err = s.createBackup(ctx, w, []string{"covers", "documents"})
if err != nil {
log.Error("Unable to save backup file: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save backup file"}, nil
}
// Remove data (mirroring legacy removeData)
log.Info("Removing data...")
err = s.removeData()
if err != nil {
log.Error("Unable to delete data: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to delete data"}, nil
}
// Restore data (mirroring legacy restoreData)
log.Info("Restoring data...")
err = s.restoreData(zipReader)
if err != nil {
log.Error("Unable to restore data: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to restore data"}, nil
}
// Reload DB (mirroring legacy Reload)
log.Info("Reloading database...")
if err := s.db.Reload(ctx); err != nil {
log.Error("Unable to reload DB: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to reload DB"}, nil
}
// Rotate auth hashes (mirroring legacy rotateAllAuthHashes)
log.Info("Rotating auth hashes...")
if err := s.rotateAllAuthHashes(ctx); err != nil {
log.Error("Unable to rotate hashes: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to rotate hashes"}, nil
}
return PostAdminAction200ApplicationoctetStreamResponse{
Body: strings.NewReader("Restore completed successfully"),
log.Info("Restore completed successfully")
return PostAdminAction200JSONResponse{
Message: "Restore completed successfully",
}, nil
}
// createBackup creates a backup ZIP archive, mirroring legacy createBackup
func (s *Server) createBackup(ctx context.Context, w io.Writer, directories []string) error {
// Vacuum DB (mirroring legacy logic)
// Vacuum DB
_, err := s.db.DB.ExecContext(ctx, "VACUUM;")
if err != nil {
return err
return fmt.Errorf("Unable to vacuum database: %w", err)
}
ar := zip.NewWriter(w)
defer ar.Close()
// Helper function to walk and archive files
exportWalker := func(currentPath string, f fs.DirEntry, err error) error {
@@ -319,6 +339,8 @@ func (s *Server) createBackup(ctx context.Context, w io.Writer, directories []st
}
}
// Close writer to flush all data before returning
ar.Close()
return nil
}
@@ -345,6 +367,10 @@ func (s *Server) removeData() error {
// restoreData restores data from ZIP archive, mirroring legacy restoreData
func (s *Server) restoreData(zipReader *zip.Reader) error {
// Ensure Directories
s.cfg.EnsureDirectories()
// Restore Data
for _, file := range zipReader.File {
rc, err := file.Open()
if err != nil {
@@ -355,12 +381,14 @@ func (s *Server) restoreData(zipReader *zip.Reader) error {
destPath := filepath.Join(s.cfg.DataPath, file.Name)
destFile, err := os.Create(destPath)
if err != nil {
log.Errorf("error creating destination file: %v", err)
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, rc)
if err != nil {
// Copy the contents from the zip file to the destination file.
if _, err := io.Copy(destFile, rc); err != nil {
log.Errorf("Error copying file contents: %v", err)
return err
}
}

View File

@@ -101,16 +101,16 @@ func (e OperationType) Valid() bool {
}
}
// Defines values for PostAdminActionFormdataBodyAction.
// Defines values for PostAdminActionMultipartBodyAction.
const (
BACKUP PostAdminActionFormdataBodyAction = "BACKUP"
CACHETABLES PostAdminActionFormdataBodyAction = "CACHE_TABLES"
METADATAMATCH PostAdminActionFormdataBodyAction = "METADATA_MATCH"
RESTORE PostAdminActionFormdataBodyAction = "RESTORE"
BACKUP PostAdminActionMultipartBodyAction = "BACKUP"
CACHETABLES PostAdminActionMultipartBodyAction = "CACHE_TABLES"
METADATAMATCH PostAdminActionMultipartBodyAction = "METADATA_MATCH"
RESTORE PostAdminActionMultipartBodyAction = "RESTORE"
)
// Valid indicates whether the value is a known member of the PostAdminActionFormdataBodyAction enum.
func (e PostAdminActionFormdataBodyAction) Valid() bool {
// Valid indicates whether the value is a known member of the PostAdminActionMultipartBodyAction enum.
func (e PostAdminActionMultipartBodyAction) Valid() bool {
switch e {
case BACKUP:
return true
@@ -315,6 +315,11 @@ type LogsResponse struct {
Logs *[]LogEntry `json:"logs,omitempty"`
}
// MessageResponse defines model for MessageResponse.
type MessageResponse struct {
Message string `json:"message"`
}
// OperationType defines model for OperationType.
type OperationType string
@@ -436,15 +441,15 @@ type GetActivityParams struct {
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
}
// PostAdminActionFormdataBody defines parameters for PostAdminAction.
type PostAdminActionFormdataBody struct {
Action PostAdminActionFormdataBodyAction `form:"action" json:"action"`
BackupTypes *[]BackupType `form:"backup_types,omitempty" json:"backup_types,omitempty"`
RestoreFile *openapi_types.File `form:"restore_file,omitempty" json:"restore_file,omitempty"`
// PostAdminActionMultipartBody defines parameters for PostAdminAction.
type PostAdminActionMultipartBody struct {
Action PostAdminActionMultipartBodyAction `json:"action"`
BackupTypes *[]BackupType `json:"backup_types,omitempty"`
RestoreFile *openapi_types.File `json:"restore_file,omitempty"`
}
// PostAdminActionFormdataBodyAction defines parameters for PostAdminAction.
type PostAdminActionFormdataBodyAction string
// PostAdminActionMultipartBodyAction defines parameters for PostAdminAction.
type PostAdminActionMultipartBodyAction string
// GetImportDirectoryParams defines parameters for GetImportDirectory.
type GetImportDirectoryParams struct {
@@ -507,8 +512,8 @@ type PostSearchFormdataBody struct {
Title string `form:"title" json:"title"`
}
// PostAdminActionFormdataRequestBody defines body for PostAdminAction for application/x-www-form-urlencoded ContentType.
type PostAdminActionFormdataRequestBody PostAdminActionFormdataBody
// PostAdminActionMultipartRequestBody defines body for PostAdminAction for multipart/form-data ContentType.
type PostAdminActionMultipartRequestBody PostAdminActionMultipartBody
// PostImportFormdataRequestBody defines body for PostImport for application/x-www-form-urlencoded ContentType.
type PostImportFormdataRequestBody PostImportFormdataBody
@@ -575,6 +580,12 @@ type ServerInterface interface {
// Get a single document
// (GET /documents/{id})
GetDocument(w http.ResponseWriter, r *http.Request, id string)
// Get document cover image
// (GET /documents/{id}/cover)
GetDocumentCover(w http.ResponseWriter, r *http.Request, id string)
// Download document file
// (GET /documents/{id}/file)
GetDocumentFile(w http.ResponseWriter, r *http.Request, id string)
// Get home page data
// (GET /home)
GetHome(w http.ResponseWriter, r *http.Request)
@@ -1021,6 +1032,68 @@ func (siw *ServerInterfaceWrapper) GetDocument(w http.ResponseWriter, r *http.Re
handler.ServeHTTP(w, r)
}
// GetDocumentCover operation middleware
func (siw *ServerInterfaceWrapper) GetDocumentCover(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetDocumentCover(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetDocumentFile operation middleware
func (siw *ServerInterfaceWrapper) GetDocumentFile(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetDocumentFile(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetHome operation middleware
func (siw *ServerInterfaceWrapper) GetHome(w http.ResponseWriter, r *http.Request) {
@@ -1431,6 +1504,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc("GET "+options.BaseURL+"/documents", wrapper.GetDocuments)
m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}", wrapper.GetDocument)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/cover", wrapper.GetDocumentCover)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/file", wrapper.GetDocumentFile)
m.HandleFunc("GET "+options.BaseURL+"/home", wrapper.GetHome)
m.HandleFunc("GET "+options.BaseURL+"/home/graph", wrapper.GetGraphData)
m.HandleFunc("GET "+options.BaseURL+"/home/statistics", wrapper.GetUserStatistics)
@@ -1508,13 +1583,22 @@ func (response GetAdmin401JSONResponse) VisitGetAdminResponse(w http.ResponseWri
}
type PostAdminActionRequestObject struct {
Body *PostAdminActionFormdataRequestBody
Body *multipart.Reader
}
type PostAdminActionResponseObject interface {
VisitPostAdminActionResponse(w http.ResponseWriter) error
}
type PostAdminAction200JSONResponse MessageResponse
func (response PostAdminAction200JSONResponse) VisitPostAdminActionResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type PostAdminAction200ApplicationoctetStreamResponse struct {
Body io.Reader
ContentLength int64
@@ -2003,6 +2087,133 @@ func (response GetDocument500JSONResponse) VisitGetDocumentResponse(w http.Respo
return json.NewEncoder(w).Encode(response)
}
type GetDocumentCoverRequestObject struct {
Id string `json:"id"`
}
type GetDocumentCoverResponseObject interface {
VisitGetDocumentCoverResponse(w http.ResponseWriter) error
}
type GetDocumentCover200ImagejpegResponse struct {
Body io.Reader
ContentLength int64
}
func (response GetDocumentCover200ImagejpegResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "image/jpeg")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.ReadCloser); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
type GetDocumentCover200ImagepngResponse struct {
Body io.Reader
ContentLength int64
}
func (response GetDocumentCover200ImagepngResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "image/png")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.ReadCloser); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
type GetDocumentCover401JSONResponse ErrorResponse
func (response GetDocumentCover401JSONResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentCover404JSONResponse ErrorResponse
func (response GetDocumentCover404JSONResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentCover500JSONResponse ErrorResponse
func (response GetDocumentCover500JSONResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentFileRequestObject struct {
Id string `json:"id"`
}
type GetDocumentFileResponseObject interface {
VisitGetDocumentFileResponse(w http.ResponseWriter) error
}
type GetDocumentFile200ApplicationoctetStreamResponse struct {
Body io.Reader
ContentLength int64
}
func (response GetDocumentFile200ApplicationoctetStreamResponse) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/octet-stream")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.ReadCloser); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
type GetDocumentFile401JSONResponse ErrorResponse
func (response GetDocumentFile401JSONResponse) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentFile404JSONResponse ErrorResponse
func (response GetDocumentFile404JSONResponse) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentFile500JSONResponse ErrorResponse
func (response GetDocumentFile500JSONResponse) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
return json.NewEncoder(w).Encode(response)
}
type GetHomeRequestObject struct {
}
@@ -2421,6 +2632,12 @@ type StrictServerInterface interface {
// Get a single document
// (GET /documents/{id})
GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error)
// Get document cover image
// (GET /documents/{id}/cover)
GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error)
// Download document file
// (GET /documents/{id}/file)
GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error)
// Get home page data
// (GET /home)
GetHome(ctx context.Context, request GetHomeRequestObject) (GetHomeResponseObject, error)
@@ -2536,16 +2753,12 @@ func (sh *strictHandler) GetAdmin(w http.ResponseWriter, r *http.Request) {
func (sh *strictHandler) PostAdminAction(w http.ResponseWriter, r *http.Request) {
var request PostAdminActionRequestObject
if err := r.ParseForm(); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode formdata: %w", err))
if reader, err := r.MultipartReader(); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err))
return
} else {
request.Body = reader
}
var body PostAdminActionFormdataRequestBody
if err := runtime.BindForm(&body, r.Form, nil, nil); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't bind formdata: %w", err))
return
}
request.Body = &body
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.PostAdminAction(ctx, request.(PostAdminActionRequestObject))
@@ -2899,6 +3112,58 @@ func (sh *strictHandler) GetDocument(w http.ResponseWriter, r *http.Request, id
}
}
// GetDocumentCover operation middleware
func (sh *strictHandler) GetDocumentCover(w http.ResponseWriter, r *http.Request, id string) {
var request GetDocumentCoverRequestObject
request.Id = id
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetDocumentCover(ctx, request.(GetDocumentCoverRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetDocumentCover")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetDocumentCoverResponseObject); ok {
if err := validResponse.VisitGetDocumentCoverResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetDocumentFile operation middleware
func (sh *strictHandler) GetDocumentFile(w http.ResponseWriter, r *http.Request, id string) {
var request GetDocumentFileRequestObject
request.Id = id
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetDocumentFile(ctx, request.(GetDocumentFileRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetDocumentFile")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetDocumentFileResponseObject); ok {
if err := validResponse.VisitGetDocumentFileResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetHome operation middleware
func (sh *strictHandler) GetHome(w http.ResponseWriter, r *http.Request) {
var request GetHomeRequestObject

View File

@@ -44,7 +44,7 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
return Login500JSONResponse{Code: 500, Message: "Internal context error"}, nil
}
// Create session
// Create session with cookie options for Vite proxy compatibility
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
@@ -53,6 +53,17 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
}
session, _ := store.Get(r, "token")
// Configure cookie options to work with Vite proxy
// For localhost development, we need SameSite to allow cookies across ports
session.Options.SameSite = http.SameSiteLaxMode
session.Options.HttpOnly = true
if !s.cfg.CookieSecure {
session.Options.Secure = false // Allow HTTP for localhost development
} else {
session.Options.Secure = true
}
session.Values["authorizedUser"] = user.ID
session.Values["isAdmin"] = user.Admin
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
@@ -82,8 +93,25 @@ func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (Logou
return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
}
// Create session store
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
}
}
session, _ := store.Get(r, "token")
// Configure cookie options (same as login)
session.Options.SameSite = http.SameSiteLaxMode
session.Options.HttpOnly = true
if !s.cfg.CookieSecure {
session.Options.Secure = false
} else {
session.Options.Secure = true
}
session.Values = make(map[any]any)
if err := session.Save(r, w); err != nil {

View File

@@ -46,7 +46,7 @@ func TestAuth(t *testing.T) {
func (suite *AuthTestSuite) SetupTest() {
suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *AuthTestSuite) createTestUser(username, password string) {

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
@@ -197,6 +199,221 @@ func parseInterfaceTime(t interface{}) *time.Time {
}
}
// serveNoCover serves the default no-cover image from assets
func (s *Server) serveNoCover() (fs.File, string, int64, error) {
// Try to open the no-cover image from assets
file, err := s.assets.Open("assets/images/no-cover.jpg")
if err != nil {
return nil, "", 0, err
}
// Get file info
info, err := file.Stat()
if err != nil {
file.Close()
return nil, "", 0, err
}
return file, "image/jpeg", info.Size(), nil
}
// openFileReader opens a file and returns it as an io.ReaderCloser
func openFileReader(path string) (*os.File, error) {
return os.Open(path)
}
// GET /documents/{id}/cover
func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error) {
// Authentication is handled by middleware, which also adds auth data to context
// This endpoint just serves the cover image
// Validate Document Exists in DB
document, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
log.Error("GetDocument DB Error:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
var coverFile fs.File
var contentType string
var contentLength int64
var needMetadataFetch bool
// Handle Identified Document
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
// Serve no-cover image
file, ct, size, err := s.serveNoCover()
if err != nil {
log.Error("Failed to open no-cover image:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
}
coverFile = file
contentType = ct
contentLength = size
needMetadataFetch = true
} else {
// Derive Path
coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile)
// Validate File Exists
fileInfo, err := os.Stat(coverPath)
if os.IsNotExist(err) {
log.Error("Cover file should but doesn't exist: ", err)
// Serve no-cover image
file, ct, size, err := s.serveNoCover()
if err != nil {
log.Error("Failed to open no-cover image:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
}
coverFile = file
contentType = ct
contentLength = size
needMetadataFetch = true
} else {
// Open the cover file
file, err := openFileReader(coverPath)
if err != nil {
log.Error("Failed to open cover file:", err)
return GetDocumentCover500JSONResponse{Code: 500, Message: "Failed to open cover"}, nil
}
coverFile = file
contentLength = fileInfo.Size()
// Determine content type based on file extension
contentType = "image/jpeg"
if strings.HasSuffix(coverPath, ".png") {
contentType = "image/png"
}
}
}
} else {
needMetadataFetch = true
}
// Attempt Metadata fetch if needed
var cachedCoverFile string = "UNKNOWN"
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
if needMetadataFetch {
// Identify Documents & Save Covers
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
Title: document.Title,
Author: document.Author,
})
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
firstResult := metadataResults[0]
// Save Cover
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
if err == nil {
cachedCoverFile = *fileName
}
// Store First Metadata Result
if _, err = s.db.Queries.AddMetadata(ctx, database.AddMetadataParams{
DocumentID: document.ID,
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)
}
}
// Upsert Document
if _, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: document.ID,
Coverfile: &cachedCoverFile,
}); err != nil {
log.Warn("UpsertDocument DB Error:", err)
}
// Update cover file if we got a new cover
if cachedCoverFile != "UNKNOWN" {
coverPath := filepath.Join(coverDir, cachedCoverFile)
fileInfo, err := os.Stat(coverPath)
if err != nil {
log.Error("Failed to stat cached cover:", err)
// Keep the no-cover image
} else {
file, err := openFileReader(coverPath)
if err != nil {
log.Error("Failed to open cached cover:", err)
// Keep the no-cover image
} else {
_ = coverFile.Close() // Close the previous file
coverFile = file
contentLength = fileInfo.Size()
// Determine content type based on file extension
contentType = "image/jpeg"
if strings.HasSuffix(coverPath, ".png") {
contentType = "image/png"
}
}
}
}
}
return &GetDocumentCover200Response{
Body: coverFile,
ContentLength: contentLength,
ContentType: contentType,
}, nil
}
// GET /documents/{id}/file
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
// Authentication is handled by middleware, which also adds auth data to context
// This endpoint just serves the document file download
// Get Document
document, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
log.Error("GetDocument DB Error:", err)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
if document.Filepath == nil {
log.Error("Document Doesn't Have File:", request.Id)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
}
// Derive Basepath
basepath := filepath.Join(s.cfg.DataPath, "documents")
if document.Basepath != nil && *document.Basepath != "" {
basepath = *document.Basepath
}
// Derive Storage Location
filePath := filepath.Join(basepath, *document.Filepath)
// Validate File Exists
fileInfo, err := os.Stat(filePath)
if os.IsNotExist(err) {
log.Error("File should but doesn't exist:", err)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
}
// Open file
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open document file:", err)
return GetDocumentFile500JSONResponse{Code: 500, Message: "Failed to open document"}, nil
}
return &GetDocumentFile200Response{
Body: file,
ContentLength: fileInfo.Size(),
Filename: filepath.Base(*document.Filepath),
}, nil
}
// POST /documents
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
@@ -339,3 +556,46 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
return CreateDocument200JSONResponse(response), nil
}
// GetDocumentCover200Response is a custom response type that allows setting content type
type GetDocumentCover200Response struct {
Body io.Reader
ContentLength int64
ContentType string
}
func (response GetDocumentCover200Response) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", response.ContentType)
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.Closer); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
// GetDocumentFile200Response is a custom response type that allows setting filename for download
type GetDocumentFile200Response struct {
Body io.Reader
ContentLength int64
Filename string
}
func (response GetDocumentFile200Response) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/octet-stream")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", response.Filename))
w.WriteHeader(200)
if closer, ok := response.Body.(io.Closer); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}

View File

@@ -47,7 +47,7 @@ func TestDocuments(t *testing.T) {
func (suite *DocumentsTestSuite) SetupTest() {
suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *DocumentsTestSuite) createTestUser(username, password string) {
@@ -158,4 +158,22 @@ func (suite *DocumentsTestSuite) TestAPIGetDocumentNotFound() {
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusNotFound, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentCoverUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/cover", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentFileUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/file", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}

View File

@@ -359,6 +359,14 @@ components:
- code
- message
MessageResponse:
type: object
properties:
message:
type: string
required:
- message
DatabaseInfo:
type: object
properties:
@@ -744,6 +752,86 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/documents/{id}/cover:
get:
summary: Get document cover image
operationId: getDocumentCover
tags:
- Documents
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- BearerAuth: []
responses:
200:
description: Cover image
content:
image/jpeg: {}
image/png: {}
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
404:
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/documents/{id}/file:
get:
summary: Download document file
operationId: getDocumentFile
tags:
- Documents
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- BearerAuth: []
responses:
200:
description: Document file download
content:
application/octet-stream:
schema:
type: string
format: binary
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
404:
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/progress:
get:
summary: List progress records
@@ -1257,7 +1345,7 @@ paths:
requestBody:
required: true
content:
application/x-www-form-urlencoded:
multipart/form-data:
schema:
type: object
properties:
@@ -1279,6 +1367,9 @@ paths:
200:
description: Action completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
application/octet-stream:
schema:
type: string

View File

@@ -3,6 +3,7 @@ package v1
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"reichard.io/antholume/config"
@@ -12,17 +13,19 @@ import (
var _ StrictServerInterface = (*Server)(nil)
type Server struct {
mux *http.ServeMux
db *database.DBManager
cfg *config.Config
mux *http.ServeMux
db *database.DBManager
cfg *config.Config
assets fs.FS
}
// NewServer creates a new native HTTP server
func NewServer(db *database.DBManager, cfg *config.Config) *Server {
func NewServer(db *database.DBManager, cfg *config.Config, assets fs.FS) *Server {
s := &Server{
mux: http.NewServeMux(),
db: db,
cfg: cfg,
mux: http.NewServeMux(),
db: db,
cfg: cfg,
assets: assets,
}
// Create strict handler with authentication middleware
@@ -43,7 +46,7 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
ctx = context.WithValue(ctx, "request", r)
ctx = context.WithValue(ctx, "response", w)
// Skip auth for login endpoint
// Skip auth for login endpoint only - cover and file require auth via cookies
if operationID == "Login" {
return handler(ctx, w, r, request)
}

View File

@@ -38,7 +38,7 @@ func (suite *ServerTestSuite) SetupTest() {
}
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *ServerTestSuite) TestNewServer() {