package handlers import ( "context" "database/sql" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "time" "lookbook/internal/data/item" "lookbook/internal/data/media" "lookbook/internal/data/tag" "lookbook/internal/embed" "lookbook/internal/opengraph" "lookbook/internal/video" ) // strOrNil returns nil if s is empty, otherwise a pointer to s. func strOrNil(s string) *string { if s == "" { return nil } return &s } // nullStr creates a sql.Null[string] from a string (empty string = invalid). func nullStr(s string) sql.Null[string] { if s == "" { return sql.Null[string]{} } return sql.Null[string]{V: s, Valid: true} } // downloadAndStoreImages downloads images from URLs and stores them for an item. // All images are stored with media_type "image". The first image (by ID order) serves as the thumbnail. // If imageURLs is empty but imageURL is set, stores that single image. func downloadAndStoreImages( ctx context.Context, db *sql.DB, logger *slog.Logger, itemID int64, imageURL string, imageURLs []string, ) { if len(imageURLs) > 0 { // Multi-image (e.g., Twitter with multiple photos) for i, imgURL := range imageURLs { imgData, contentType, err := opengraph.DownloadImage(ctx, imgURL) if err != nil { logger.Warn("failed to download image", "url", imgURL, "index", i, "error", err) continue } _, err = media.QCreate(ctx, db, media.CreateParams{ ItemID: itemID, MediaType: "image", ContentType: contentType, Data: imgData, SourceURL: &imgURL, }) if err != nil { logger.Warn("failed to store image", "index", i, "error", err) } } } else if imageURL != "" { // Single image imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL) if err != nil { logger.Warn("failed to download image", "url", imageURL, "error", err) return } _, err = media.QCreate(ctx, db, media.CreateParams{ ItemID: itemID, MediaType: "image", ContentType: contentType, Data: imgData, SourceURL: &imageURL, }) if err != nil { logger.Warn("failed to store image", "error", err) } } } type urlMetadata struct { Title string Description string ImageURL string ImageURLs []string // All image URLs (for multi-image tweets) SiteName string IsEmbed bool Provider string VideoID string EmbedHTML string VideoURL string // Direct video URL (for Twitter) MediaType string // "video", "images", "text" - for Twitter only } func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) { // Check if it's a YouTube/Vimeo/Twitter embed embedInfo, err := embed.Detect(ctx, url) if err == nil && embedInfo != nil { return &urlMetadata{ Title: embedInfo.Title, Description: embedInfo.Description, ImageURL: embedInfo.ThumbnailURL, ImageURLs: embedInfo.ThumbnailURLs, IsEmbed: true, Provider: string(embedInfo.Provider), VideoID: embedInfo.VideoID, EmbedHTML: embedInfo.EmbedHTML, VideoURL: embedInfo.VideoURL, MediaType: embedInfo.MediaType, }, nil } // Fetch OpenGraph metadata meta, err := opengraph.Fetch(ctx, url) if err != nil { return nil, err } return &urlMetadata{ Title: meta.Title, Description: meta.Description, ImageURL: meta.ImageURL, SiteName: meta.SiteName, IsEmbed: false, }, nil } type previewResponse struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` ImageURL string `json:"imageUrl,omitempty"` SiteName string `json:"siteName,omitempty"` IsEmbed bool `json:"isEmbed"` Provider string `json:"provider,omitempty"` VideoID string `json:"videoId,omitempty"` EmbedHTML string `json:"embedHtml,omitempty"` MediaType string `json:"mediaType,omitempty"` // "video", "images", "text" - for Twitter } // HandlePreviewLink handles POST /api/preview - fetches metadata for a URL func HandlePreviewLink(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { if !rc.RequireAdmin(w) { return nil } var req struct { URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if req.URL == "" { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "url required"}) } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() meta, err := fetchURLMetadata(ctx, req.URL) if err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)}) } return writeJSON(w, http.StatusOK, previewResponse{ Title: meta.Title, Description: meta.Description, ImageURL: meta.ImageURL, SiteName: meta.SiteName, IsEmbed: meta.IsEmbed, Provider: meta.Provider, VideoID: meta.VideoID, EmbedHTML: meta.EmbedHTML, MediaType: meta.MediaType, }) } type createFromLinkRequest struct { URL string `json:"url"` Title *string `json:"title"` Description *string `json:"description"` Tags []string `json:"tags"` // For embeds: Provider *string `json:"provider"` VideoID *string `json:"videoId"` EmbedHTML *string `json:"embedHtml"` // For downloading hero image: ImageURL *string `json:"imageUrl"` } // HandleCreateFromLink handles POST /api/items/from-link func HandleCreateFromLink(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { if !rc.RequireAdmin(w) { return nil } var req createFromLinkRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() var itemType string var embedProvider, embedVideoID, embedHTML *string var imageURL, videoURL string var imageURLs []string // For multi-image tweets if req.Provider != nil && *req.Provider != "" { // Special handling for Twitter based on media content if *req.Provider == "twitter" { // Fetch tweet info to determine content type videoInfo, err := embed.Detect(ctx, req.URL) if err == nil && videoInfo != nil { if videoInfo.VideoURL != "" { // Tweet has video → keep as embed (proxied) itemType = "embed" embedProvider = req.Provider embedVideoID = req.VideoID embedHTML = req.EmbedHTML videoURL = videoInfo.VideoURL imageURL = videoInfo.ThumbnailURL } else if len(videoInfo.ThumbnailURLs) > 0 { // Tweet has image(s) → save as image itemType = "image" imageURL = videoInfo.ThumbnailURL imageURLs = videoInfo.ThumbnailURLs } else { // Text-only tweet → save as quote itemType = "quote" // title and description already set from request } } else { // If detection fails, fall back to link itemType = "link" } } else { // YouTube, Vimeo, etc. → keep as embed itemType = "embed" embedProvider = req.Provider embedVideoID = req.VideoID embedHTML = req.EmbedHTML // Fetch thumbnail for non-Twitter embeds if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil { imageURL = videoInfo.ThumbnailURL videoURL = videoInfo.VideoURL } } } else if req.ImageURL != nil && *req.ImageURL != "" { // It's a link with an image itemType = "image" imageURL = *req.ImageURL } else { // Just a link (will be shown as a card) itemType = "link" } // Create the item it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ Title: item.Nullable(req.Title), Description: item.Nullable(req.Description), LinkURL: item.Nullable(&req.URL), ItemType: itemType, EmbedProvider: item.Nullable(embedProvider), EmbedVideoID: item.Nullable(embedVideoID), EmbedHTML: item.Nullable(embedHTML), EmbedVideoURL: nullStr(videoURL), }) if err != nil { return err } // Download and store images downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, imageURL, imageURLs) // Set tags if len(req.Tags) > 0 { if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, req.Tags); err != nil { return err } } resp, err := buildItemResponse(ctx, rc, it) if err != nil { return err } return writeJSON(w, http.StatusCreated, resp) } // HandleUpload handles POST /api/items/upload - multipart file upload func HandleUpload(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { if !rc.RequireAdmin(w) { return nil } // Parse multipart form (max 500MB) if err := r.ParseMultipartForm(500 << 20); err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse form"}) } file, header, err := r.FormFile("file") if err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"}) } defer file.Close() title := r.FormValue("title") description := r.FormValue("description") tagsStr := r.FormValue("tags") var tags []string if tagsStr != "" { tags = strings.Split(tagsStr, ",") for i := range tags { tags[i] = strings.TrimSpace(tags[i]) } } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() // Read file data data, err := io.ReadAll(file) if err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read file"}) } contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = http.DetectContentType(data) } var itemType string var originalData, thumbnailData []byte var originalContentType string if video.IsVideo(contentType) { itemType = "video" // Process video: transcode and extract thumbnail transcoded, thumbnail, err := video.ProcessVideo(ctx, data, contentType) if err != nil { return writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("video processing failed: %v", err)}) } originalData = transcoded originalContentType = "video/mp4" thumbnailData = thumbnail } else if video.IsImage(contentType) { itemType = "image" originalData = data originalContentType = contentType } else { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported file type"}) } // Create item it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ Title: item.Nullable(strOrNil(title)), Description: item.Nullable(strOrNil(description)), ItemType: itemType, }) if err != nil { return err } // Store original media _, err = media.QCreate(ctx, rc.DB, media.CreateParams{ ItemID: it.ID, MediaType: "original", ContentType: originalContentType, Data: originalData, }) if err != nil { return err } // Store thumbnail for videos if len(thumbnailData) > 0 { _, err = media.QCreate(ctx, rc.DB, media.CreateParams{ ItemID: it.ID, MediaType: "thumbnail", ContentType: "image/jpeg", Data: thumbnailData, }) if err != nil { rc.Logger.Warn("failed to store thumbnail", "error", err) } } // Set tags if len(tags) > 0 { if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, tags); err != nil { return err } } resp, err := buildItemResponse(ctx, rc, it) if err != nil { return err } return writeJSON(w, http.StatusCreated, resp) } type createQuoteRequest struct { Text string `json:"text"` Source *string `json:"source"` // Optional attribution SourceURL *string `json:"sourceUrl"` Tags []string `json:"tags"` } // HandleCreateQuote handles POST /api/items/quote func HandleCreateQuote(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { if !rc.RequireAdmin(w) { return nil } var req createQuoteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if req.Text == "" { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text required"}) } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ Title: item.Nullable(req.Source), Description: nullStr(req.Text), LinkURL: item.Nullable(req.SourceURL), ItemType: "quote", }) if err != nil { return err } // Set tags if len(req.Tags) > 0 { if err := tag.QSetTagsForItem(ctx, rc.DB, it.ID, req.Tags); err != nil { return err } } resp, err := buildItemResponse(ctx, rc, it) if err != nil { return err } return writeJSON(w, http.StatusCreated, resp) } // HandleReplaceMedia handles POST /api/items/{id}/media - replaces media for an item func HandleReplaceMedia(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { if !rc.RequireAdmin(w) { return nil } pubID := r.PathValue("id") if pubID == "" { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"}) } // Parse multipart form (max 500MB) if err := r.ParseMultipartForm(500 << 20); err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse form"}) } file, header, err := r.FormFile("file") if err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"}) } defer file.Close() ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() it, err := item.QFindByPubID(ctx, rc.DB, pubID) if err != nil { return err } if it == nil { return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) } // Read file data data, err := io.ReadAll(file) if err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read file"}) } contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = http.DetectContentType(data) } var originalData, thumbnailData []byte var originalContentType string var newItemType string if video.IsVideo(contentType) { newItemType = "video" transcoded, thumbnail, err := video.ProcessVideo(ctx, data, contentType) if err != nil { return writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("video processing failed: %v", err)}) } originalData = transcoded originalContentType = "video/mp4" thumbnailData = thumbnail } else if video.IsImage(contentType) { newItemType = "image" originalData = data originalContentType = contentType } else { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported file type"}) } // Delete existing media media.QDeleteByItemID(ctx, rc.DB, it.ID) // Store new original media _, err = media.QCreate(ctx, rc.DB, media.CreateParams{ ItemID: it.ID, MediaType: "original", ContentType: originalContentType, Data: originalData, }) if err != nil { return err } // Store thumbnail for videos if len(thumbnailData) > 0 { _, err = media.QCreate(ctx, rc.DB, media.CreateParams{ ItemID: it.ID, MediaType: "thumbnail", ContentType: "image/jpeg", Data: thumbnailData, }) if err != nil { rc.Logger.Warn("failed to store thumbnail", "error", err) } } // Update item type if it changed (e.g., embed -> image) if it.ItemType != newItemType && (it.ItemType == "embed" || it.ItemType == "link") { item.QUpdateType(ctx, rc.DB, it.ID, newItemType) } // Refetch and return updated item it, err = item.QFindByID(ctx, rc.DB, it.ID) if err != nil { return err } resp, err := buildItemResponse(ctx, rc, *it) if err != nil { return err } return writeJSON(w, http.StatusOK, resp) } // HandleRefreshMetadata handles POST /api/items/{id}/refresh // Re-fetches metadata and thumbnail for an item with a link URL func HandleRefreshMetadata(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { if !rc.RequireAdmin(w) { return nil } pubID := r.PathValue("id") if pubID == "" { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"}) } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() it, err := item.QFindByPubID(ctx, rc.DB, pubID) if err != nil { return err } if it == nil { return writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) } if !it.LinkURL.Valid || it.LinkURL.V == "" { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "item has no link URL"}) } meta, err := fetchURLMetadata(ctx, it.LinkURL.V) if err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)}) } // Update title, description, and video URL item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{ Title: nullStr(meta.Title), Description: nullStr(meta.Description), LinkURL: it.LinkURL, }) if meta.VideoURL != "" { item.QUpdateVideoURL(ctx, rc.DB, it.ID, meta.VideoURL) } // Download and replace images if len(meta.ImageURLs) > 0 || meta.ImageURL != "" { // Delete existing media (thumbnails and gallery) media.QDeleteByItemID(ctx, rc.DB, it.ID) downloadAndStoreImages(ctx, rc.DB, rc.Logger, it.ID, meta.ImageURL, meta.ImageURLs) } // Refetch and return updated item it, err = item.QFindByID(ctx, rc.DB, it.ID) if err != nil { return err } resp, err := buildItemResponse(ctx, rc, *it) if err != nil { return err } return writeJSON(w, http.StatusOK, resp) }