hako/internal/model/domains.go
Felipe M. 484b225af7
feat: add user administration API and UI
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
2026-04-02 10:33:36 +02:00

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
}