This commit is contained in:
2026-03-21 20:47:22 -04:00
parent ba919bbde4
commit 4d133994ab
55 changed files with 1901 additions and 264 deletions

View File

@@ -69,7 +69,6 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
response := ActivityResponse{
Activities: apiActivities,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return GetActivity200JSONResponse(response), nil
}

View File

@@ -159,7 +159,6 @@ type Activity struct {
// ActivityResponse defines model for ActivityResponse.
type ActivityResponse struct {
Activities []Activity `json:"activities"`
User UserData `json:"user"`
}
// BackupType defines model for BackupType.
@@ -217,7 +216,6 @@ type Document struct {
type DocumentResponse struct {
Document Document `json:"document"`
Progress *Progress `json:"progress,omitempty"`
User UserData `json:"user"`
}
// DocumentsResponse defines model for DocumentsResponse.
@@ -248,7 +246,6 @@ type GraphDataPoint struct {
// GraphDataResponse defines model for GraphDataResponse.
type GraphDataResponse struct {
GraphData []GraphDataPoint `json:"graph_data"`
User UserData `json:"user"`
}
// HomeResponse defines model for HomeResponse.
@@ -256,7 +253,6 @@ type HomeResponse struct {
DatabaseInfo DatabaseInfo `json:"database_info"`
GraphData GraphDataResponse `json:"graph_data"`
Streaks StreaksResponse `json:"streaks"`
User UserData `json:"user"`
UserStatistics UserStatisticsResponse `json:"user_statistics"`
}
@@ -349,13 +345,11 @@ type ProgressListResponse struct {
PreviousPage *int64 `json:"previous_page,omitempty"`
Progress *[]Progress `json:"progress,omitempty"`
Total *int64 `json:"total,omitempty"`
User *UserData `json:"user,omitempty"`
}
// ProgressResponse defines model for ProgressResponse.
type ProgressResponse struct {
Progress *Progress `json:"progress,omitempty"`
User *UserData `json:"user,omitempty"`
}
// SearchItem defines model for SearchItem.
@@ -387,7 +381,6 @@ type SettingsResponse struct {
// StreaksResponse defines model for StreaksResponse.
type StreaksResponse struct {
Streaks []UserStreak `json:"streaks"`
User UserData `json:"user"`
}
// UpdateSettingsRequest defines model for UpdateSettingsRequest.
@@ -413,7 +406,6 @@ type UserData struct {
// UserStatisticsResponse defines model for UserStatisticsResponse.
type UserStatisticsResponse struct {
Duration LeaderboardData `json:"duration"`
User UserData `json:"user"`
Words LeaderboardData `json:"words"`
Wpm LeaderboardData `json:"wpm"`
}
@@ -495,6 +487,21 @@ type CreateDocumentMultipartBody struct {
DocumentFile openapi_types.File `json:"document_file"`
}
// EditDocumentJSONBody defines parameters for EditDocument.
type EditDocumentJSONBody struct {
Author *string `json:"author,omitempty"`
CoverGbid *string `json:"cover_gbid,omitempty"`
Description *string `json:"description,omitempty"`
Isbn10 *string `json:"isbn10,omitempty"`
Isbn13 *string `json:"isbn13,omitempty"`
Title *string `json:"title,omitempty"`
}
// UploadDocumentCoverMultipartBody defines parameters for UploadDocumentCover.
type UploadDocumentCoverMultipartBody struct {
CoverFile openapi_types.File `json:"cover_file"`
}
// GetProgressListParams defines parameters for GetProgressList.
type GetProgressListParams struct {
Page *int64 `form:"page,omitempty" json:"page,omitempty"`
@@ -534,6 +541,12 @@ type LoginJSONRequestBody = LoginRequest
// CreateDocumentMultipartRequestBody defines body for CreateDocument for multipart/form-data ContentType.
type CreateDocumentMultipartRequestBody CreateDocumentMultipartBody
// EditDocumentJSONRequestBody defines body for EditDocument for application/json ContentType.
type EditDocumentJSONRequestBody EditDocumentJSONBody
// UploadDocumentCoverMultipartRequestBody defines body for UploadDocumentCover for multipart/form-data ContentType.
type UploadDocumentCoverMultipartRequestBody UploadDocumentCoverMultipartBody
// PostSearchFormdataRequestBody defines body for PostSearch for application/x-www-form-urlencoded ContentType.
type PostSearchFormdataRequestBody PostSearchFormdataBody
@@ -587,9 +600,15 @@ type ServerInterface interface {
// Get a single document
// (GET /documents/{id})
GetDocument(w http.ResponseWriter, r *http.Request, id string)
// Update document editable fields
// (POST /documents/{id})
EditDocument(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)
// Upload document cover image
// (POST /documents/{id}/cover)
UploadDocumentCover(w http.ResponseWriter, r *http.Request, id string)
// Download document file
// (GET /documents/{id}/file)
GetDocumentFile(w http.ResponseWriter, r *http.Request, id string)
@@ -1042,6 +1061,37 @@ func (siw *ServerInterfaceWrapper) GetDocument(w http.ResponseWriter, r *http.Re
handler.ServeHTTP(w, r)
}
// EditDocument operation middleware
func (siw *ServerInterfaceWrapper) EditDocument(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.EditDocument(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetDocumentCover operation middleware
func (siw *ServerInterfaceWrapper) GetDocumentCover(w http.ResponseWriter, r *http.Request) {
@@ -1073,6 +1123,37 @@ func (siw *ServerInterfaceWrapper) GetDocumentCover(w http.ResponseWriter, r *ht
handler.ServeHTTP(w, r)
}
// UploadDocumentCover operation middleware
func (siw *ServerInterfaceWrapper) UploadDocumentCover(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.UploadDocumentCover(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) {
@@ -1528,7 +1609,9 @@ 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("POST "+options.BaseURL+"/documents/{id}", wrapper.EditDocument)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/cover", wrapper.GetDocumentCover)
m.HandleFunc("POST "+options.BaseURL+"/documents/{id}/cover", wrapper.UploadDocumentCover)
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)
@@ -2112,6 +2195,60 @@ func (response GetDocument500JSONResponse) VisitGetDocumentResponse(w http.Respo
return json.NewEncoder(w).Encode(response)
}
type EditDocumentRequestObject struct {
Id string `json:"id"`
Body *EditDocumentJSONRequestBody
}
type EditDocumentResponseObject interface {
VisitEditDocumentResponse(w http.ResponseWriter) error
}
type EditDocument200JSONResponse DocumentResponse
func (response EditDocument200JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type EditDocument400JSONResponse ErrorResponse
func (response EditDocument400JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(400)
return json.NewEncoder(w).Encode(response)
}
type EditDocument401JSONResponse ErrorResponse
func (response EditDocument401JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type EditDocument404JSONResponse ErrorResponse
func (response EditDocument404JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
return json.NewEncoder(w).Encode(response)
}
type EditDocument500JSONResponse ErrorResponse
func (response EditDocument500JSONResponse) VisitEditDocumentResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentCoverRequestObject struct {
Id string `json:"id"`
}
@@ -2185,6 +2322,60 @@ func (response GetDocumentCover500JSONResponse) VisitGetDocumentCoverResponse(w
return json.NewEncoder(w).Encode(response)
}
type UploadDocumentCoverRequestObject struct {
Id string `json:"id"`
Body *multipart.Reader
}
type UploadDocumentCoverResponseObject interface {
VisitUploadDocumentCoverResponse(w http.ResponseWriter) error
}
type UploadDocumentCover200JSONResponse DocumentResponse
func (response UploadDocumentCover200JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type UploadDocumentCover400JSONResponse ErrorResponse
func (response UploadDocumentCover400JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(400)
return json.NewEncoder(w).Encode(response)
}
type UploadDocumentCover401JSONResponse ErrorResponse
func (response UploadDocumentCover401JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type UploadDocumentCover404JSONResponse ErrorResponse
func (response UploadDocumentCover404JSONResponse) VisitUploadDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
return json.NewEncoder(w).Encode(response)
}
type UploadDocumentCover500JSONResponse ErrorResponse
func (response UploadDocumentCover500JSONResponse) VisitUploadDocumentCoverResponse(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"`
}
@@ -2682,9 +2873,15 @@ type StrictServerInterface interface {
// Get a single document
// (GET /documents/{id})
GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error)
// Update document editable fields
// (POST /documents/{id})
EditDocument(ctx context.Context, request EditDocumentRequestObject) (EditDocumentResponseObject, error)
// Get document cover image
// (GET /documents/{id}/cover)
GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error)
// Upload document cover image
// (POST /documents/{id}/cover)
UploadDocumentCover(ctx context.Context, request UploadDocumentCoverRequestObject) (UploadDocumentCoverResponseObject, error)
// Download document file
// (GET /documents/{id}/file)
GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error)
@@ -3165,6 +3362,39 @@ func (sh *strictHandler) GetDocument(w http.ResponseWriter, r *http.Request, id
}
}
// EditDocument operation middleware
func (sh *strictHandler) EditDocument(w http.ResponseWriter, r *http.Request, id string) {
var request EditDocumentRequestObject
request.Id = id
var body EditDocumentJSONRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
return
}
request.Body = &body
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.EditDocument(ctx, request.(EditDocumentRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "EditDocument")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(EditDocumentResponseObject); ok {
if err := validResponse.VisitEditDocumentResponse(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))
}
}
// GetDocumentCover operation middleware
func (sh *strictHandler) GetDocumentCover(w http.ResponseWriter, r *http.Request, id string) {
var request GetDocumentCoverRequestObject
@@ -3191,6 +3421,39 @@ func (sh *strictHandler) GetDocumentCover(w http.ResponseWriter, r *http.Request
}
}
// UploadDocumentCover operation middleware
func (sh *strictHandler) UploadDocumentCover(w http.ResponseWriter, r *http.Request, id string) {
var request UploadDocumentCoverRequestObject
request.Id = id
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
}
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.UploadDocumentCover(ctx, request.(UploadDocumentCoverRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "UploadDocumentCover")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(UploadDocumentCoverResponseObject); ok {
if err := validResponse.VisitUploadDocumentCoverResponse(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

View File

@@ -52,7 +52,10 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
}
}
session, _ := store.Get(r, "token")
session, err := store.Get(r, "token")
if err != nil {
return Login401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
// Configure cookie options to work with Vite proxy
// For localhost development, we need SameSite to allow cookies across ports
@@ -101,7 +104,10 @@ func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (Logou
}
}
session, _ := store.Get(r, "token")
session, err := store.Get(r, "token")
if err != nil {
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
// Configure cookie options (same as login)
session.Options.SameSite = http.SameSiteLaxMode
@@ -143,6 +149,15 @@ func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
return auth, true
}
// isAdmin checks if a user has admin privileges
func (s *Server) isAdmin(ctx context.Context) bool {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return false
}
return auth.IsAdmin
}
// getRequestFromContext extracts the HTTP request from context
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
r, ok := ctx.Value("request").(*http.Request)

View File

@@ -154,12 +154,111 @@ func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObje
response := DocumentResponse{
Document: apiDoc,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Progress: progress,
}
return GetDocument200JSONResponse(response), nil
}
// POST /documents/{id}
func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestObject) (EditDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return EditDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return EditDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Validate document exists and get current state
currentDoc, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
// Validate at least one editable field is provided
if request.Body.Title == nil &&
request.Body.Author == nil &&
request.Body.Description == nil &&
request.Body.Isbn10 == nil &&
request.Body.Isbn13 == nil &&
request.Body.CoverGbid == nil {
return EditDocument400JSONResponse{Code: 400, Message: "No editable fields provided"}, nil
}
// Handle cover via Google Books ID
var coverFileName *string
if request.Body.CoverGbid != nil {
coverDir := filepath.Join(s.cfg.DataPath, "covers")
fileName, err := metadata.CacheCoverWithContext(ctx, *request.Body.CoverGbid, coverDir, request.Id, true)
if err == nil {
coverFileName = fileName
}
}
// Update document with provided editable fields only
updatedDoc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Id,
Title: request.Body.Title,
Author: request.Body.Author,
Description: request.Body.Description,
Isbn10: request.Body.Isbn10,
Isbn13: request.Body.Isbn13,
Coverfile: coverFileName,
// Preserve existing values for non-editable fields
Md5: currentDoc.Md5,
Basepath: currentDoc.Basepath,
Filepath: currentDoc.Filepath,
Words: currentDoc.Words,
})
if err != nil {
log.Error("UpsertDocument DB Error:", err)
return EditDocument500JSONResponse{Code: 500, Message: "Failed to update document"}, nil
}
// Get progress for the document
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: request.Id,
})
var progress *Progress
if err == nil {
progress = &Progress{
UserId: &progressRow.UserID,
DocumentId: &progressRow.DocumentID,
DeviceName: &progressRow.DeviceName,
Percentage: &progressRow.Percentage,
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
}
}
var percentage *float32
if progress != nil && progress.Percentage != nil {
percentage = ptrOf(float32(*progress.Percentage))
}
apiDoc := Document{
Id: updatedDoc.ID,
Title: *updatedDoc.Title,
Author: *updatedDoc.Author,
Description: updatedDoc.Description,
Isbn10: updatedDoc.Isbn10,
Isbn13: updatedDoc.Isbn13,
Words: updatedDoc.Words,
Filepath: updatedDoc.Filepath,
CreatedAt: parseTime(updatedDoc.CreatedAt),
UpdatedAt: parseTime(updatedDoc.UpdatedAt),
Deleted: updatedDoc.Deleted,
Percentage: percentage,
}
response := DocumentResponse{
Document: apiDoc,
Progress: progress,
}
return EditDocument200JSONResponse(response), nil
}
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
// Derive New FileName
@@ -296,8 +395,12 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
if needMetadataFetch {
// Create context with timeout for metadata service calls
metadataCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Identify Documents & Save Covers
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
metadataResults, err := metadata.SearchMetadataWithContext(metadataCtx, metadata.SOURCE_GBOOK, metadata.MetadataInfo{
Title: document.Title,
Author: document.Author,
})
@@ -306,7 +409,7 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
firstResult := metadataResults[0]
// Save Cover
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
fileName, err := metadata.CacheCoverWithContext(metadataCtx, *firstResult.ID, coverDir, document.ID, false)
if err == nil {
cachedCoverFile = *fileName
}
@@ -368,6 +471,136 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
}, nil
}
// POST /documents/{id}/cover
func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocumentCoverRequestObject) (UploadDocumentCoverResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return UploadDocumentCover401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Validate document exists
_, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
// Read multipart form
form, err := request.Body.ReadForm(32 << 20) // 32MB max
if err != nil {
log.Error("ReadForm error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
}
// Get file from form
fileField := form.File["cover_file"]
if len(fileField) == 0 {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "No file provided"}, nil
}
file := fileField[0]
// Validate file extension
if !strings.HasSuffix(strings.ToLower(file.Filename), ".jpg") && !strings.HasSuffix(strings.ToLower(file.Filename), ".png") {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Only JPG and PNG files are allowed"}, nil
}
// Open file
f, err := file.Open()
if err != nil {
log.Error("Open file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
}
defer f.Close()
// Read file content
data, err := io.ReadAll(f)
if err != nil {
log.Error("Read file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
}
// Validate actual content type
contentType := http.DetectContentType(data)
allowedTypes := map[string]bool{
"image/jpeg": true,
"image/png": true,
}
if !allowedTypes[contentType] {
return UploadDocumentCover400JSONResponse{
Code: 400,
Message: fmt.Sprintf("Invalid file type: %s. Only JPG and PNG files are allowed.", contentType),
}, nil
}
// Derive storage path
coverDir := filepath.Join(s.cfg.DataPath, "covers")
fileName := fmt.Sprintf("%s%s", request.Id, strings.ToLower(filepath.Ext(file.Filename)))
safePath := filepath.Join(coverDir, fileName)
// Save file
err = os.WriteFile(safePath, data, 0644)
if err != nil {
log.Error("Save file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Unable to save cover"}, nil
}
// Upsert document with new cover
updatedDoc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Id,
Coverfile: &fileName,
})
if err != nil {
log.Error("UpsertDocument DB error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to save cover"}, nil
}
// Get progress for the document
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: request.Id,
})
var progress *Progress
if err == nil {
progress = &Progress{
UserId: &progressRow.UserID,
DocumentId: &progressRow.DocumentID,
DeviceName: &progressRow.DeviceName,
Percentage: &progressRow.Percentage,
CreatedAt: ptrOf(parseTime(progressRow.CreatedAt)),
}
}
var percentage *float32
if progress != nil && progress.Percentage != nil {
percentage = ptrOf(float32(*progress.Percentage))
}
apiDoc := Document{
Id: updatedDoc.ID,
Title: *updatedDoc.Title,
Author: *updatedDoc.Author,
Description: updatedDoc.Description,
Isbn10: updatedDoc.Isbn10,
Isbn13: updatedDoc.Isbn13,
Words: updatedDoc.Words,
Filepath: updatedDoc.Filepath,
CreatedAt: parseTime(updatedDoc.CreatedAt),
UpdatedAt: parseTime(updatedDoc.UpdatedAt),
Deleted: updatedDoc.Deleted,
Percentage: percentage,
}
response := DocumentResponse{
Document: apiDoc,
Progress: progress,
}
return UploadDocumentCover200JSONResponse(response), 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
@@ -416,7 +649,7 @@ func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileReq
// POST /documents
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
_, ok := s.getSessionFromContext(ctx)
if !ok {
return CreateDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
@@ -460,6 +693,15 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
}
// Validate actual content type
contentType := http.DetectContentType(data)
if contentType != "application/epub+zip" && contentType != "application/zip" {
return CreateDocument400JSONResponse{
Code: 400,
Message: fmt.Sprintf("Invalid file type: %s. Only EPUB files are allowed.", contentType),
}, nil
}
// Create temp file to get metadata
tempFile, err := os.CreateTemp("", "book")
if err != nil {
@@ -502,7 +744,6 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
}
response := DocumentResponse{
Document: apiDoc,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return CreateDocument200JSONResponse(response), nil
}
@@ -551,7 +792,6 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
response := DocumentResponse{
Document: apiDoc,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return CreateDocument200JSONResponse(response), nil

View File

@@ -54,23 +54,11 @@ func (s *Server) GetHome(ctx context.Context, request GetHomeRequestObject) (Get
},
Streaks: StreaksResponse{
Streaks: convertStreaks(streaks),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
},
GraphData: GraphDataResponse{
GraphData: convertGraphData(graphData),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
},
UserStatistics: arrangeUserStatistics(userStats),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
}
return GetHome200JSONResponse(response), nil
@@ -91,10 +79,6 @@ func (s *Server) GetStreaks(ctx context.Context, request GetStreaksRequestObject
response := StreaksResponse{
Streaks: convertStreaks(streaks),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
}
return GetStreaks200JSONResponse(response), nil
@@ -115,10 +99,6 @@ func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestOb
response := GraphDataResponse{
GraphData: convertGraphData(graphData),
User: UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
},
}
return GetGraphData200JSONResponse(response), nil
@@ -126,7 +106,7 @@ func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestOb
// GET /home/statistics
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
_, ok := s.getSessionFromContext(ctx)
if !ok {
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
@@ -138,11 +118,6 @@ func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatistic
}
response := arrangeUserStatistics(userStats)
response.User = UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
}
return GetUserStatistics200JSONResponse(response), nil
}

View File

@@ -233,13 +233,10 @@ components:
properties:
document:
$ref: '#/components/schemas/Document'
user:
$ref: '#/components/schemas/UserData'
progress:
$ref: '#/components/schemas/Progress'
required:
- document
- user
ProgressListResponse:
type: object
@@ -248,8 +245,6 @@ components:
type: array
items:
$ref: '#/components/schemas/Progress'
user:
$ref: '#/components/schemas/UserData'
page:
type: integer
format: int64
@@ -271,8 +266,6 @@ components:
properties:
progress:
$ref: '#/components/schemas/Progress'
user:
$ref: '#/components/schemas/UserData'
ActivityResponse:
type: object
@@ -281,11 +274,8 @@ components:
type: array
items:
$ref: '#/components/schemas/Activity'
user:
$ref: '#/components/schemas/UserData'
required:
- activities
- user
Device:
type: object
@@ -423,11 +413,8 @@ components:
type: array
items:
$ref: '#/components/schemas/UserStreak'
user:
$ref: '#/components/schemas/UserData'
required:
- streaks
- user
GraphDataPoint:
type: object
@@ -448,11 +435,8 @@ components:
type: array
items:
$ref: '#/components/schemas/GraphDataPoint'
user:
$ref: '#/components/schemas/UserData'
required:
- graph_data
- user
LeaderboardEntry:
type: object
@@ -500,13 +484,10 @@ components:
$ref: '#/components/schemas/LeaderboardData'
words:
$ref: '#/components/schemas/LeaderboardData'
user:
$ref: '#/components/schemas/UserData'
required:
- wpm
- duration
- words
- user
HomeResponse:
type: object
@@ -519,14 +500,11 @@ components:
$ref: '#/components/schemas/GraphDataResponse'
user_statistics:
$ref: '#/components/schemas/UserStatisticsResponse'
user:
$ref: '#/components/schemas/UserData'
required:
- database_info
- streaks
- graph_data
- user_statistics
- user
BackupType:
type: string
@@ -765,6 +743,69 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
post:
summary: Update document editable fields
operationId: editDocument
tags:
- Documents
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
author:
type: string
description:
type: string
isbn10:
type: string
isbn13:
type: string
cover_gbid:
type: string
security:
- BearerAuth: []
responses:
200:
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentResponse'
400:
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
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}/cover:
get:
@@ -804,6 +845,62 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
post:
summary: Upload document cover image
operationId: uploadDocumentCover
tags:
- Documents
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
cover_file:
type: string
format: binary
required:
- cover_file
security:
- BearerAuth: []
responses:
200:
description: Cover uploaded
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentResponse'
400:
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
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:

View File

@@ -70,7 +70,6 @@ func (s *Server) GetProgressList(ctx context.Context, request GetProgressListReq
response := ProgressListResponse{
Progress: &apiProgress,
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Page: &page,
Limit: &limit,
NextPage: nextPage,
@@ -119,7 +118,6 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
response := ProgressResponse{
Progress: &apiProgress,
User: &UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return GetProgress200JSONResponse(response), nil

View File

@@ -60,6 +60,28 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
return nil, nil
}
// Check admin status for admin-only endpoints
adminEndpoints := []string{
"GetAdmin",
"PostAdminAction",
"GetUsers",
"UpdateUser",
"GetImportDirectory",
"PostImport",
"GetImportResults",
"GetLogs",
}
for _, adminEndpoint := range adminEndpoints {
if operationID == adminEndpoint && !auth.IsAdmin {
// Write 403 response directly
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(403)
json.NewEncoder(w).Encode(ErrorResponse{Code: 403, Message: "Admin privileges required"})
return nil, nil
}
}
// Store auth in context for handlers to access
ctx = context.WithValue(ctx, "auth", auth)