Add admin-only CRUD endpoints for user management under /api/v1/system/users with full frontend implementation including create, edit, change password, and delete operations with self-protection guards. - Extend AuthDomain interface with ListUsers, GetUser, UpdateUser, UpdateUserPassword, DeleteUser methods - Add List, Update, Delete methods to UserStore with shared scanUser helper - Create UserHandler with 5 endpoints protected by admin middleware - Complete Users.vue admin page with modals and error handling - Make CreateUser accept role parameter for atomic user creation - Fix auth middleware to use database role as source of truth instead of stale JWT claims, preventing privilege persistence after demotion - Fix pre-existing gofmt issues in yt_dlp.go and config.go
162 lines
5.2 KiB
Go
162 lines
5.2 KiB
Go
package model
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
)
|
|
|
|
// ArchiveFileResponse represents an archive file in API responses
|
|
type ArchiveFileResponse struct {
|
|
ID string `json:"id"`
|
|
ArchiveID string `json:"archive_id"`
|
|
ArchiverKey string `json:"extractor_key"` // JSON tag kept for backward compatibility
|
|
Filename string `json:"filename"`
|
|
MimeType string `json:"mime_type"`
|
|
FileSize int64 `json:"file_size"`
|
|
HashSha256 string `json:"hash_sha256"`
|
|
CreatedAt string `json:"created_at"`
|
|
DownloadURL string `json:"download_url"`
|
|
Content string `json:"content,omitempty"`
|
|
ContentMimeType string `json:"content_mime_type,omitempty"`
|
|
}
|
|
|
|
// CategoryResponse represents a category in API responses
|
|
type CategoryResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon"`
|
|
}
|
|
|
|
// LinkListItem represents a link in the domain layer for API responses
|
|
// This separates API concerns from database persistence models
|
|
type LinkListItem struct {
|
|
Link
|
|
|
|
// Computed/aggregated fields
|
|
LatestArchiveTitle string `json:"latest_archive_title,omitempty"`
|
|
LatestArchiveStatus string `json:"latest_archive_status,omitempty"`
|
|
Thumbnail *ArchiveFileResponse `json:"thumbnail,omitempty"`
|
|
Categories []CategoryResponse `json:"categories,omitempty"`
|
|
|
|
// Internal fields (not serialized)
|
|
ThumbnailURL string `json:"-"` // Only populated if IncludeThumbnail is true
|
|
HasThumbnail bool `json:"-"` // Indicates if thumbnail exists
|
|
}
|
|
|
|
// LinkDomain defines the interface for link operations
|
|
type LinkDomain interface {
|
|
CreateLink(ctx context.Context, url, userID string) (*Link, error)
|
|
GetLink(ctx context.Context, id string) (*Link, error)
|
|
ListLinks(ctx context.Context, req LinkListRequest) ([]LinkListItem, int, error)
|
|
DeleteLink(ctx context.Context, id string) error
|
|
}
|
|
|
|
// ArchiveDomain defines the interface for archive operations
|
|
type ArchiveDomain interface {
|
|
GetArchive(ctx context.Context, archiveID string) (*Archive, error)
|
|
GetArchiveHistory(ctx context.Context, linkID string) ([]*Archive, error)
|
|
ReArchiveLink(ctx context.Context, linkID string) error
|
|
GetArchiveFiles(ctx context.Context, archiveID string) ([]*ArchiveFile, error)
|
|
GetArchiveFile(ctx context.Context, fileID string) (*ArchiveFile, error)
|
|
DeleteArchive(ctx context.Context, archiveID string) error
|
|
ProcessArchive(ctx context.Context, archive *Archive, link *Link, archiverKeys []string) error
|
|
}
|
|
|
|
// CategoryDomain defines the interface for category operations
|
|
type CategoryDomain interface {
|
|
DetermineCategoriesFromMimeType(ctx context.Context, mimeType string) ([]string, error)
|
|
UpdateLinkCategories(ctx context.Context, linkID string, mimeTypes []string) error
|
|
ListCategories(ctx context.Context) ([]*Category, error)
|
|
}
|
|
|
|
// AuthDomain defines the interface for authentication operations
|
|
type AuthDomain interface {
|
|
Login(email, password string) (*User, *Token, error)
|
|
CreateUser(email, password, role string) (*User, error)
|
|
VerifyPassword(user *User, password string) bool
|
|
ListUsers() ([]*User, error)
|
|
GetUser(id string) (*User, error)
|
|
UpdateUser(id, email, role string) (*User, error)
|
|
UpdateUserPassword(id, password string) error
|
|
DeleteUser(id string) error
|
|
}
|
|
|
|
// LinkListRequest contains request parameters for listing links
|
|
type LinkListRequest struct {
|
|
UserID string
|
|
Limit int
|
|
Offset int
|
|
CategoryID string
|
|
IncludeThumbnail bool
|
|
SearchQuery string
|
|
}
|
|
|
|
// Defaults sets sensible defaults for LinkListRequest
|
|
func (r *LinkListRequest) Defaults() {
|
|
if r.Limit == 0 {
|
|
r.Limit = 50 // Default limit
|
|
}
|
|
if r.Offset < 0 {
|
|
r.Offset = 0
|
|
}
|
|
}
|
|
|
|
// IsValid validates LinkListRequest
|
|
func (r *LinkListRequest) IsValid() error {
|
|
if r.UserID == "" {
|
|
return ErrInvalidRequest
|
|
}
|
|
if r.Limit < 1 || r.Limit > 100 {
|
|
return ErrInvalidRequest
|
|
}
|
|
if r.Offset < 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FromURLValues parses query parameters from url.Values into the request struct
|
|
func (r *LinkListRequest) FromURLValues(values url.Values) error {
|
|
// Parse limit
|
|
if limitStr := values.Get("limit"); limitStr != "" {
|
|
limit, err := strconv.Atoi(limitStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid limit parameter: %w", err)
|
|
}
|
|
r.Limit = limit
|
|
}
|
|
|
|
// Parse offset
|
|
if offsetStr := values.Get("offset"); offsetStr != "" {
|
|
offset, err := strconv.Atoi(offsetStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid offset parameter: %w", err)
|
|
}
|
|
r.Offset = offset
|
|
}
|
|
|
|
// Parse category_id
|
|
if categoryID := values.Get("category_id"); categoryID != "" {
|
|
r.CategoryID = categoryID
|
|
}
|
|
|
|
// Parse include_thumbnail
|
|
if includeThumbnailStr := values.Get("include_thumbnail"); includeThumbnailStr != "" {
|
|
includeThumbnail, err := strconv.ParseBool(includeThumbnailStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid include_thumbnail parameter: %w", err)
|
|
}
|
|
r.IncludeThumbnail = includeThumbnail
|
|
}
|
|
|
|
// Parse search query (supports both 'q' and 'search' parameters)
|
|
if searchQuery := values.Get("q"); searchQuery != "" {
|
|
r.SearchQuery = searchQuery
|
|
} else if searchQuery := values.Get("search"); searchQuery != "" {
|
|
r.SearchQuery = searchQuery
|
|
}
|
|
|
|
return nil
|
|
}
|