hako/internal/server/handlers/links.go
2026-01-18 09:51:25 +01:00

280 lines
8.3 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"git.nakama.town/fmartingr/hako/internal/dependencies"
"git.nakama.town/fmartingr/hako/internal/model"
"git.nakama.town/fmartingr/hako/internal/server/webcontext"
)
// LinkHandler handles link-related HTTP requests
type LinkHandler struct {
deps model.Dependencies
logger *slog.Logger
}
// NewLinkHandler creates a new LinkHandler
func NewLinkHandler(deps model.Dependencies, logger *slog.Logger) *LinkHandler {
return &LinkHandler{
deps: deps,
logger: logger,
}
}
// getDeps returns the concrete dependencies implementation
func (h *LinkHandler) getDeps() *dependencies.Dependencies {
return h.deps.(*dependencies.Dependencies)
}
// CreateLinkRequest represents a request to create a link
type CreateLinkRequest struct {
URL string `json:"url"`
}
// ListLinksResponse represents a paginated list of links
type ListLinksResponse struct {
Items []model.LinkListItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// HandleCreateLink handles POST /api/v1/links
func (h *LinkHandler) HandleCreateLink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
c := webcontext.NewWebContext(w, r)
// Get user ID from context
userID := c.GetUserID()
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse request body
var req CreateLinkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate URL
if req.URL == "" {
http.Error(w, "URL is required", http.StatusBadRequest)
return
}
// Create link
link, err := h.deps.Domains().Links().CreateLink(c.Context(), req.URL, userID)
if err != nil {
h.logger.Error("Failed to create link", "error", err, "url", req.URL, "user_id", userID)
http.Error(w, fmt.Sprintf("Failed to create link: %v", err), http.StatusInternalServerError)
return
}
h.logger.Info("Link created successfully", "link_id", link.ID, "url", req.URL, "user_id", userID)
// Build response using LinkListItem
response := model.LinkListItem{
Link: *link,
}
c.ResponseWriter().Header().Set("Content-Type", "application/json")
c.ResponseWriter().WriteHeader(http.StatusCreated)
_ = json.NewEncoder(c.ResponseWriter()).Encode(response)
}
// HandleGetLink handles GET /api/v1/links/{id}
func (h *LinkHandler) HandleGetLink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
c := webcontext.NewWebContext(w, r)
// Extract ID from path
id := r.PathValue("id")
if id == "" {
http.Error(w, "Link ID is required", http.StatusBadRequest)
return
}
// Get link
link, err := h.deps.Domains().Links().GetLink(c.Context(), id)
if err != nil {
http.Error(w, "Link not found", http.StatusNotFound)
return
}
// Build response using LinkListItem
response := model.LinkListItem{
Link: model.Link{
ID: link.ID,
URL: link.URL,
UserID: link.UserID,
TotalSize: link.TotalSize,
CreatedAt: link.CreatedAt,
UpdatedAt: link.UpdatedAt,
},
}
// Get latest archive for this link
deps := h.getDeps()
latestArchive, err := deps.ArchiveStore.GetLatestByLinkID(c.Context(), link.ID)
if err == nil && latestArchive != nil {
response.LatestArchiveTitle = latestArchive.Title
response.LatestArchiveStatus = string(latestArchive.Status)
// Get thumbnail using new query method
thumbnailFile, err := deps.ArchiveFileStore.GetThumbnailByArchiveID(c.Context(), latestArchive.ID)
if err == nil && thumbnailFile != nil {
// Convert to ArchiveFileResponse
response.Thumbnail = &model.ArchiveFileResponse{
ID: thumbnailFile.ID,
ArchiveID: thumbnailFile.ArchiveID,
ArchiverKey: thumbnailFile.ArchiverKey,
Filename: thumbnailFile.Filename,
MimeType: thumbnailFile.MimeType,
FileSize: thumbnailFile.FileSize,
HashSha256: thumbnailFile.HashSha256,
CreatedAt: thumbnailFile.CreatedAt.Format("2006-01-02T15:04:05Z"),
DownloadURL: fmt.Sprintf("/api/v1/archives/%s/files/%s/download", latestArchive.ID, thumbnailFile.ID),
Content: thumbnailFile.Content,
ContentMimeType: thumbnailFile.ContentMimeType,
}
}
}
// Get categories for this link
categories, err := deps.LinkCategoryStore.GetCategoriesByLinkID(c.Context(), link.ID)
if err == nil {
response.Categories = make([]model.CategoryResponse, 0, len(categories))
for _, cat := range categories {
response.Categories = append(response.Categories, model.CategoryResponse{
ID: cat.ID,
Name: cat.Name,
Icon: cat.Icon,
})
}
}
c.ResponseWriter().Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(c.ResponseWriter()).Encode(response)
}
// HandleListLinks handles GET /api/v1/links
func (h *LinkHandler) HandleListLinks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
c := webcontext.NewWebContext(w, r)
// Get user ID from context
userID := c.GetUserID()
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse request from URL query parameters
req := model.LinkListRequest{UserID: userID}
if err := req.FromURLValues(r.URL.Query()); err != nil {
http.Error(w, fmt.Sprintf("Invalid query parameters: %v", err), http.StatusBadRequest)
return
}
req.Defaults()
if err := req.IsValid(); err != nil {
http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest)
return
}
// Debug: log search query if present
if req.SearchQuery != "" {
h.logger.Info("Search query received", "query", req.SearchQuery, "userID", userID)
}
// List links (domain layer handles thumbnail lookup)
linkItems, total, err := h.deps.Domains().Links().ListLinks(c.Context(), req)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to list links: %v", err), http.StatusInternalServerError)
return
}
// Build response from domain structs - populate Thumbnail if available
deps := h.getDeps()
for i := range linkItems {
item := &linkItems[i]
// Get thumbnail file if available
if item.HasThumbnail {
thumbnailFile, err := deps.ArchiveFileStore.GetThumbnailByLinkID(c.Context(), item.ID)
if err == nil && thumbnailFile != nil {
// Get the archive ID for the thumbnail
latestArchive, err := deps.ArchiveStore.GetLatestByLinkID(c.Context(), item.ID)
if err == nil && latestArchive != nil {
// Convert to ArchiveFileResponse
item.Thumbnail = &model.ArchiveFileResponse{
ID: thumbnailFile.ID,
ArchiveID: thumbnailFile.ArchiveID,
ArchiverKey: thumbnailFile.ArchiverKey,
Filename: thumbnailFile.Filename,
MimeType: thumbnailFile.MimeType,
FileSize: thumbnailFile.FileSize,
HashSha256: thumbnailFile.HashSha256,
CreatedAt: thumbnailFile.CreatedAt.Format("2006-01-02T15:04:05Z"),
DownloadURL: fmt.Sprintf("/api/v1/archives/%s/files/%s/download", latestArchive.ID, thumbnailFile.ID),
Content: thumbnailFile.Content,
ContentMimeType: thumbnailFile.ContentMimeType,
}
}
}
}
}
response := ListLinksResponse{
Items: linkItems,
Total: total,
Limit: req.Limit,
Offset: req.Offset,
}
c.ResponseWriter().Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(c.ResponseWriter()).Encode(response)
}
// HandleDeleteLink handles DELETE /api/v1/links/{id}
func (h *LinkHandler) HandleDeleteLink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
c := webcontext.NewWebContext(w, r)
// Extract ID from path
id := r.PathValue("id")
if id == "" {
http.Error(w, "Link ID is required", http.StatusBadRequest)
return
}
// Delete link
if err := h.deps.Domains().Links().DeleteLink(c.Context(), id); err != nil {
http.Error(w, fmt.Sprintf("Failed to delete link: %v", err), http.StatusInternalServerError)
return
}
c.ResponseWriter().WriteHeader(http.StatusNoContent)
}