diff --git a/AGENTS.md b/AGENTS.md index 97486d6..634c3f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,84 +1,33 @@ -# Agent Context Hints +# AnthoLume - Agent Context -## Current Status -Currently mid migration from go templates (`./templates`) to React App (`./frontend`) +## Migration Context +Updating Go templates (rendered HTML) → React app using V1 API (OpenAPI spec) -## Architecture Context -- **Backend**: Go with Gin router (legacy), SQLC for database queries, currently migrating to V1 API (oapi-codegen) -- **Frontend**: React with Vite, currently migrating from Go templates (using the V1 API) -- **API**: OpenAPI 3.0 spec, generates Go server (oapi-codegen) and TS client (orval) +## Critical Rules -## Frontend Linting -The frontend uses ESLint and Prettier for code quality and formatting. +### Database Access +- **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql` +- Migrate V1 API by mirroring legacy implementation in `api/app-admin-routes.go` and `api/app-routes.go` -### Running Linting -- **Check linting**: `cd frontend && bun run lint` -- **Fix linting issues**: `cd frontend && bun run lint:fix` -- **Check formatting**: `cd frontend && bun run format` -- **Format files**: `cd frontend && bun run format:fix` +### Migration Workflow +1. Check legacy implementation for business logic +2. Copy pattern but adapt to use `s.db.Queries.*` instead of `api.db.Queries.*` +3. Map legacy response types to V1 API response types +4. Never create new DB queries -### When to Run Linting -Run linting after making any changes to the frontend to ensure code quality and consistency. All new code should pass linting before committing. +### Surprises +- Templates may show fields the API doesn't return - cross-check with DB query +- `start_time` is `interface{}` in Go models, needs type assertion in Go +- Templates use `LOCAL_TIME()` SQL function for timezone-aware display -### Package Management -The frontend uses **bun** for package management. Use: -- `bun install` to install dependencies -- `bun add ` to add a new package -- `bun remove ` to remove a package -- `bunx ` to run CLI binaries locally +## Error Handling +Use `fmt.Errorf("message: %w", err)` for wrapping. Do NOT use `github.com/pkg/errors`. -## Data Flow (CRITICAL for migrations) -1. Database schema → SQL queries (`database/query.sql`, `database/query.sql.go`) -2. SQLC models → API handlers (`api/v1/*.go`) -3. Go templates show **intended UI** structure (`templates/pages/*.tmpl`) -4. API spec defines actual API contract (`api/v1/openapi.yaml`) -5. Generated TS client → React components +## Frontend +- **Package manager**: bun (not npm) +- **Lint**: `cd frontend && bun run lint` (and `lint:fix`) +- **Format**: `cd frontend && bun run format` (and `format:fix`) -## When Migrating from Go Templates -- Check template AND database query results (Go templates may show fields API doesn't return) -- Template columns often map to: document_id, title, author, start_time, duration, start/end_percentage -- Go template rendering: `{{ template "component/table" }}` with "Columns" and "Keys" - -## API Regeneration Commands +## Regeneration - Go backend: `go generate ./api/v1/generate.go` - TS client: `cd frontend && npm run generate:api` - -## Frontend Key Files and Directories -- **Source code**: `frontend/src/` -- **Configuration**: `frontend/eslint.config.js`, `frontend/.prettierrc`, `frontend/tsconfig.json` -- **Build output**: `frontend/dist/` -- **Generated API client**: `frontend/src/generated/` - -## Key Files -- Database queries: `database/query.sql` → SQLc Query shows actual fields returned -- SQLC models: `database/query.sql.go` → SQLc Generated Go struct definitions -- Go templates: `templates/pages/*.tmpl` → Legacy UI reference -- API spec: `api/v1/openapi.yaml` → contract definition -- Generated TS types: `frontend/src/generated/model/*.ts` - -## Common Gotchas -- API implementation may not map all fields from DB query (check `api/v1/activity.go` mapping) -- `start_time` is `interface{}` in Go models, needs type assertion -- Go templates use `LOCAL_TIME()` SQL function for timezone-aware display - -## CRITICAL: Migration Implementation Rules -- **NEVER write ad-hoc SQL queries** - All database access must use existing SQLC queries from `database/query.sql` -- **Mirror legacy implementation** - Check `api/app-admin-routes.go`, `api/app-routes.go` for existing business logic -- **Reuse existing functions** - Look for helper functions in `api/utils.go` that handle file operations, metadata, etc. -- **SQLC query reference** - Check `database/query.sql` for available queries and `database/query.sql.go` for function signatures -- **When implementing TODOs in v1 API**: - 1. Find the corresponding function in legacy API (e.g., `api/app-admin-routes.go`) - 2. Copy the logic pattern but adapt to use `s.db.Queries.*` instead of `api.db.Queries.*` - 3. Use existing helper functions from `api/utils.go` (make them accessible if needed) - 4. Map legacy response types to new v1 API response types - 5. Never create new database queries - use what SQLC already provides - -## API Structure -- **Legacy API**: `api/` directory (e.g., `api/app-admin-routes.go`, `api/app-routes.go`) - - Uses Gin router - - Renders Go templates - - Contains all existing business logic to mirror -- **V1 API**: `api/v1/` directory (e.g., `api/v1/admin.go`, `api/v1/documents.go`) - - Uses oapi-codegen (OpenAPI spec driven) - - Returns JSON responses - - Currently being migrated from legacy patterns diff --git a/api/api.go b/api/api.go index 168e82a..054aeda 100644 --- a/api/api.go +++ b/api/api.go @@ -16,7 +16,6 @@ import ( "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/microcosm-cc/bluemonday" - "github.com/pkg/errors" log "github.com/sirupsen/logrus" "reichard.io/antholume/config" "reichard.io/antholume/database" @@ -298,7 +297,7 @@ func (api *API) loadTemplates( templateDirectory := fmt.Sprintf("templates/%ss", basePath) allFiles, err := fs.ReadDir(api.assets, templateDirectory) if err != nil { - return errors.Wrap(err, fmt.Sprintf("unable to read template dir: %s", templateDirectory)) + return fmt.Errorf("unable to read template dir %s: %w", templateDirectory, err) } // Generate Templates @@ -310,7 +309,7 @@ func (api *API) loadTemplates( // Read Template b, err := fs.ReadFile(api.assets, templatePath) if err != nil { - return errors.Wrap(err, fmt.Sprintf("unable to read template: %s", templateName)) + return fmt.Errorf("unable to read template %s: %w", templateName, err) } // Clone? (Pages - Don't Stomp) @@ -321,7 +320,7 @@ func (api *API) loadTemplates( // Parse Template baseTemplate, err = baseTemplate.New(templateName).Parse(string(b)) if err != nil { - return errors.Wrap(err, fmt.Sprintf("unable to parse template: %s", templateName)) + return fmt.Errorf("unable to parse template %s: %w", templateName, err) } allTemplates[templateName] = baseTemplate diff --git a/api/app-admin-routes.go b/api/app-admin-routes.go index d43645f..6da9529 100644 --- a/api/app-admin-routes.go +++ b/api/app-admin-routes.go @@ -22,7 +22,6 @@ import ( "github.com/gabriel-vasile/mimetype" "github.com/gin-gonic/gin" "github.com/itchyny/gojq" - "github.com/pkg/errors" log "github.com/sirupsen/logrus" "reichard.io/antholume/database" "reichard.io/antholume/metadata" @@ -722,7 +721,7 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str // Vacuum DB _, err := api.db.DB.ExecContext(ctx, "VACUUM;") if err != nil { - return errors.Wrap(err, "Unable to vacuum database") + return fmt.Errorf("Unable to vacuum database: %w", err) } ar := zip.NewWriter(w) @@ -796,7 +795,7 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) { allUsers, err := api.db.Queries.GetUsers(ctx) if err != nil { - return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err)) + return false, fmt.Errorf("GetUsers DB Error: %w", err) } hasAdmin := false @@ -873,7 +872,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string } else { user, err := api.db.Queries.GetUser(ctx, user) if err != nil { - return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err)) + return fmt.Errorf("GetUser DB Error: %w", err) } updateParams.Admin = user.Admin } @@ -911,7 +910,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string // Update User _, err := api.db.Queries.UpdateUser(ctx, updateParams) if err != nil { - return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err)) + return fmt.Errorf("UpdateUser DB Error: %w", err) } return nil @@ -943,7 +942,7 @@ func (api *API) deleteUser(ctx context.Context, user string) error { // Delete User _, err = api.db.Queries.DeleteUser(ctx, user) if err != nil { - return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err)) + return fmt.Errorf("DeleteUser DB Error: %w", err) } return nil diff --git a/api/v1/admin.go b/api/v1/admin.go index 178f5e7..892b742 100644 --- a/api/v1/admin.go +++ b/api/v1/admin.go @@ -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 } } diff --git a/api/v1/api.gen.go b/api/v1/api.gen.go index f33719d..202a1a7 100644 --- a/api/v1/api.gen.go +++ b/api/v1/api.gen.go @@ -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 diff --git a/api/v1/auth.go b/api/v1/auth.go index 58df79d..57c87f6 100644 --- a/api/v1/auth.go +++ b/api/v1/auth.go @@ -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 { diff --git a/api/v1/auth_test.go b/api/v1/auth_test.go index dbe30ca..5067da0 100644 --- a/api/v1/auth_test.go +++ b/api/v1/auth_test.go @@ -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) { diff --git a/api/v1/documents.go b/api/v1/documents.go index b1ad363..9c15eb3 100644 --- a/api/v1/documents.go +++ b/api/v1/documents.go @@ -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 +} diff --git a/api/v1/documents_test.go b/api/v1/documents_test.go index 71ff15e..d39e14a 100644 --- a/api/v1/documents_test.go +++ b/api/v1/documents_test.go @@ -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) } \ No newline at end of file diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index 264fbd1..da79ca1 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -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 diff --git a/api/v1/server.go b/api/v1/server.go index d6fa5c6..f6f94a4 100644 --- a/api/v1/server.go +++ b/api/v1/server.go @@ -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) } diff --git a/api/v1/server_test.go b/api/v1/server_test.go index 93682a8..0d1a5c1 100644 --- a/api/v1/server_test.go +++ b/api/v1/server_test.go @@ -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() { diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 61ecb36..42ccccd 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -35,23 +35,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAuthState(prev => { if (meLoading) { // Still checking authentication + console.log('[AuthContext] Checking authentication status...'); return { ...prev, isCheckingAuth: true }; - } else if (meData?.data) { - // User is authenticated + } else if (meData?.data && meData.status === 200) { + // User is authenticated - check that response has valid data + console.log('[AuthContext] User authenticated:', meData.data); return { isAuthenticated: true, user: meData.data, isCheckingAuth: false, }; - } else if (meError) { + } else if ( + meError || + (meData && meData.status === 401) || + (meData && meData.status === 403) + ) { // User is not authenticated or error occurred + console.log('[AuthContext] User not authenticated:', meError?.message || String(meError)); return { isAuthenticated: false, user: null, isCheckingAuth: false, }; } - return prev; + console.log('[AuthContext] Unexpected state - checking...'); + return { ...prev, isCheckingAuth: false }; // Assume not authenticated if we can't determine }); }, [meData, meError, meLoading]); @@ -75,6 +83,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { navigate('/'); } catch (_error) { + console.error('[AuthContext] Login failed:', _error); throw new Error('Login failed'); } }, diff --git a/frontend/src/components/ToastContext.tsx b/frontend/src/components/ToastContext.tsx index 8ed15d0..0e12ec7 100644 --- a/frontend/src/components/ToastContext.tsx +++ b/frontend/src/components/ToastContext.tsx @@ -77,11 +77,11 @@ function ToastContainer({ toasts }: ToastContainerProps) { return (
-
- {toasts.map(toast => ( - - ))} -
+ {toasts.map(toast => ( +
+ +
+ ))}
); } diff --git a/frontend/src/generated/anthoLumeAPIV1.ts b/frontend/src/generated/anthoLumeAPIV1.ts index 341510b..4955ffd 100644 --- a/frontend/src/generated/anthoLumeAPIV1.ts +++ b/frontend/src/generated/anthoLumeAPIV1.ts @@ -44,6 +44,7 @@ import type { LoginRequest, LoginResponse, LogsResponse, + MessageResponse, PostAdminActionBody, PostImportBody, PostSearchBody, @@ -440,6 +441,279 @@ export function useGetDocument>, +/** + * @summary Get document cover image + */ +export type getDocumentCoverResponse200ImageJpeg = { + data: Blob + status: 200 +} + +export type getDocumentCoverResponse200ImagePng = { + data: Blob + status: 200 +} + +export type getDocumentCoverResponse401 = { + data: ErrorResponse + status: 401 +} + +export type getDocumentCoverResponse404 = { + data: ErrorResponse + status: 404 +} + +export type getDocumentCoverResponse500 = { + data: ErrorResponse + status: 500 +} + +export type getDocumentCoverResponseSuccess = (getDocumentCoverResponse200ImageJpeg | getDocumentCoverResponse200ImagePng) & { + headers: Headers; +}; +export type getDocumentCoverResponseError = (getDocumentCoverResponse401 | getDocumentCoverResponse404 | getDocumentCoverResponse500) & { + headers: Headers; +}; + +export type getDocumentCoverResponse = (getDocumentCoverResponseSuccess | getDocumentCoverResponseError) + +export const getGetDocumentCoverUrl = (id: string,) => { + + + + + return `/api/v1/documents/${id}/cover` +} + +export const getDocumentCover = async (id: string, options?: RequestInit): Promise => { + + const res = await fetch(getGetDocumentCoverUrl(id), + { + ...options, + method: 'GET' + + + } +) + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getDocumentCoverResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as getDocumentCoverResponse +} + + + + + +export const getGetDocumentCoverQueryKey = (id: string,) => { + return [ + `/api/v1/documents/${id}/cover` + ] as const; + } + + +export const getGetDocumentCoverQueryOptions = >, TError = ErrorResponse>(id: string, options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} +) => { + +const {query: queryOptions, fetch: fetchOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetDocumentCoverQueryKey(id); + + + + const queryFn: QueryFunction>> = ({ signal }) => getDocumentCover(id, { signal, ...fetchOptions }); + + + + + + return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetDocumentCoverQueryResult = NonNullable>> +export type GetDocumentCoverQueryError = ErrorResponse + + +export function useGetDocumentCover>, TError = ErrorResponse>( + id: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, fetch?: RequestInit} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetDocumentCover>, TError = ErrorResponse>( + id: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetDocumentCover>, TError = ErrorResponse>( + id: string, options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get document cover image + */ + +export function useGetDocumentCover>, TError = ErrorResponse>( + id: string, options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetDocumentCoverQueryOptions(id,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + +/** + * @summary Download document file + */ +export type getDocumentFileResponse200 = { + data: Blob + status: 200 +} + +export type getDocumentFileResponse401 = { + data: ErrorResponse + status: 401 +} + +export type getDocumentFileResponse404 = { + data: ErrorResponse + status: 404 +} + +export type getDocumentFileResponse500 = { + data: ErrorResponse + status: 500 +} + +export type getDocumentFileResponseSuccess = (getDocumentFileResponse200) & { + headers: Headers; +}; +export type getDocumentFileResponseError = (getDocumentFileResponse401 | getDocumentFileResponse404 | getDocumentFileResponse500) & { + headers: Headers; +}; + +export type getDocumentFileResponse = (getDocumentFileResponseSuccess | getDocumentFileResponseError) + +export const getGetDocumentFileUrl = (id: string,) => { + + + + + return `/api/v1/documents/${id}/file` +} + +export const getDocumentFile = async (id: string, options?: RequestInit): Promise => { + + const res = await fetch(getGetDocumentFileUrl(id), + { + ...options, + method: 'GET' + + + } +) + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getDocumentFileResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as getDocumentFileResponse +} + + + + + +export const getGetDocumentFileQueryKey = (id: string,) => { + return [ + `/api/v1/documents/${id}/file` + ] as const; + } + + +export const getGetDocumentFileQueryOptions = >, TError = ErrorResponse>(id: string, options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} +) => { + +const {query: queryOptions, fetch: fetchOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetDocumentFileQueryKey(id); + + + + const queryFn: QueryFunction>> = ({ signal }) => getDocumentFile(id, { signal, ...fetchOptions }); + + + + + + return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetDocumentFileQueryResult = NonNullable>> +export type GetDocumentFileQueryError = ErrorResponse + + +export function useGetDocumentFile>, TError = ErrorResponse>( + id: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, fetch?: RequestInit} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetDocumentFile>, TError = ErrorResponse>( + id: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetDocumentFile>, TError = ErrorResponse>( + id: string, options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Download document file + */ + +export function useGetDocumentFile>, TError = ErrorResponse>( + id: string, options?: { query?:Partial>, TError, TData>>, fetch?: RequestInit} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetDocumentFileQueryOptions(id,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + /** * @summary List progress records */ @@ -2296,7 +2570,12 @@ export function useGetAdmin>, TError /** * @summary Perform admin action (backup, restore, etc.) */ -export type postAdminActionResponse200 = { +export type postAdminActionResponse200ApplicationJson = { + data: MessageResponse + status: 200 +} + +export type postAdminActionResponse200ApplicationOctetStream = { data: Blob status: 200 } @@ -2316,7 +2595,7 @@ export type postAdminActionResponse500 = { status: 500 } -export type postAdminActionResponseSuccess = (postAdminActionResponse200) & { +export type postAdminActionResponseSuccess = (postAdminActionResponse200ApplicationJson | postAdminActionResponse200ApplicationOctetStream) & { headers: Headers; }; export type postAdminActionResponseError = (postAdminActionResponse400 | postAdminActionResponse401 | postAdminActionResponse500) & { @@ -2334,22 +2613,22 @@ export const getPostAdminActionUrl = () => { } export const postAdminAction = async (postAdminActionBody: PostAdminActionBody, options?: RequestInit): Promise => { - const formUrlEncoded = new URLSearchParams(); -formUrlEncoded.append(`action`, postAdminActionBody.action); + const formData = new FormData(); +formData.append(`action`, postAdminActionBody.action); if(postAdminActionBody.backup_types !== undefined) { - postAdminActionBody.backup_types.forEach(value => formUrlEncoded.append(`backup_types`, value)); + postAdminActionBody.backup_types.forEach(value => formData.append(`backup_types`, value)); } if(postAdminActionBody.restore_file !== undefined) { - formUrlEncoded.append(`restore_file`, postAdminActionBody.restore_file); + formData.append(`restore_file`, postAdminActionBody.restore_file); } const res = await fetch(getPostAdminActionUrl(), { ...options, - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...options?.headers }, + method: 'POST' + , body: - formUrlEncoded, + formData, } ) diff --git a/frontend/src/generated/model/index.ts b/frontend/src/generated/model/index.ts index 9bfb671..83be27c 100644 --- a/frontend/src/generated/model/index.ts +++ b/frontend/src/generated/model/index.ts @@ -39,6 +39,7 @@ export * from './logEntry'; export * from './loginRequest'; export * from './loginResponse'; export * from './logsResponse'; +export * from './messageResponse'; export * from './operationType'; export * from './postAdminActionBody'; export * from './postAdminActionBodyAction'; diff --git a/frontend/src/generated/model/messageResponse.ts b/frontend/src/generated/model/messageResponse.ts new file mode 100644 index 0000000..e5b3084 --- /dev/null +++ b/frontend/src/generated/model/messageResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * AnthoLume API v1 + * REST API for AnthoLume document management system + * OpenAPI spec version: 1.0.0 + */ + +export interface MessageResponse { + message: string; +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 9c387ec..18a5216 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -19,52 +19,79 @@ export default function AdminPage() { }); const [restoreFile, setRestoreFile] = useState(null); - const handleBackupSubmit = (e: FormEvent) => { + const handleBackupSubmit = async (e: FormEvent) => { e.preventDefault(); const backupTypesList: string[] = []; if (backupTypes.covers) backupTypesList.push('COVERS'); if (backupTypes.documents) backupTypesList.push('DOCUMENTS'); - postAdminAction.mutate( - { - data: { - action: 'BACKUP', - backup_types: backupTypesList as any, - }, - }, - { - onSuccess: response => { - // Handle file download - const url = window.URL.createObjectURL(new Blob([response.data])); - const link = document.createElement('a'); - link.href = url; - link.setAttribute( - 'download', - `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip` - ); - document.body.appendChild(link); - link.click(); - link.remove(); - showInfo('Backup completed successfully'); - }, - onError: error => { - showError('Backup failed: ' + (error as any).message); - }, + try { + const formData = new FormData(); + formData.append('action', 'BACKUP'); + backupTypesList.forEach(value => formData.append('backup_types', value)); + + const response = await fetch('/api/v1/admin', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('Backup failed: ' + response.statusText); } - ); + + const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`; + + // Stream the response directly to disk using File System Access API + // This avoids loading multi-GB files into browser memory + if (typeof (window as any).showSaveFilePicker === 'function') { + try { + // Modern browsers: Use File System Access API for direct disk writes + const handle = await (window as any).showSaveFilePicker({ + suggestedName: filename, + types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], + }); + + const writable = await handle.createWritable(); + + // Stream response body directly to file without buffering + const reader = response.body?.getReader(); + if (!reader) throw new Error('Unable to read response'); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + await writable.write(value); + } + + await writable.close(); + showInfo('Backup completed successfully'); + } catch (err) { + // User cancelled or error + if ((err as Error).name !== 'AbortError') { + showError('Backup failed: ' + (err as Error).message); + } + } + } else { + // Fallback for older browsers + showError( + 'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.' + ); + } + } catch (error) { + showError('Backup failed: ' + (error as any).message); + } }; const handleRestoreSubmit = (e: FormEvent) => { e.preventDefault(); if (!restoreFile) return; - const formData = new FormData(); - formData.append('restore_file', restoreFile); - formData.append('action', 'RESTORE'); - postAdminAction.mutate( { - data: formData as any, + data: { + action: 'RESTORE', + restore_file: restoreFile, + }, }, { onSuccess: () => { diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 9fa6dc5..b02fee5 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -76,9 +76,9 @@ function DocumentCard({ doc }: DocumentCardProps) { {doc.filepath ? ( - + - + ) : ( )} diff --git a/go.mod b/go.mod index b9b7851..d0bebcb 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/jarcoal/httpmock v1.3.1 github.com/microcosm-cc/bluemonday v1.0.27 github.com/oapi-codegen/runtime v1.2.0 - github.com/pkg/errors v0.9.1 github.com/pressly/goose/v3 v3.24.3 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 49c91b7..5cdca69 100644 --- a/go.sum +++ b/go.sum @@ -93,8 +93,6 @@ github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+Lpmz github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= diff --git a/server/server.go b/server/server.go index 7d5d7ce..d0afda1 100644 --- a/server/server.go +++ b/server/server.go @@ -28,7 +28,7 @@ type server struct { func New(c *config.Config, assets fs.FS) *server { db := database.NewMgr(c) ginAPI := api.NewApi(db, c, assets) - v1API := v1.NewServer(db, c) + v1API := v1.NewServer(db, c, assets) // Create combined mux that handles both Gin and v1 API mux := http.NewServeMux()