wip 13
This commit is contained in:
120
api/v1/admin.go
120
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user