package handlers import ( "context" "encoding/json" "fmt" "io" "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" ) type urlMetadata struct { Title string Description string ImageURL string SiteName string IsEmbed bool Provider string VideoID string EmbedHTML string } // fetchURLMetadata fetches metadata for a URL, trying embed detection first, then OpenGraph func fetchURLMetadata(ctx context.Context, url string) (*urlMetadata, error) { // Check if it's a YouTube/Vimeo/Twitter embed videoInfo, err := embed.Detect(ctx, url) if err == nil && videoInfo != nil { return &urlMetadata{ Title: videoInfo.Title, Description: videoInfo.Description, ImageURL: videoInfo.ThumbnailURL, IsEmbed: true, Provider: string(videoInfo.Provider), VideoID: videoInfo.VideoID, EmbedHTML: videoInfo.EmbedHTML, }, 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"` } // 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, }) } 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 if req.Provider != nil && *req.Provider != "" { // It's an embed itemType = "embed" embedProvider = req.Provider embedVideoID = req.VideoID embedHTML = req.EmbedHTML } else if req.ImageURL != nil && *req.ImageURL != "" { // It's a link with an image itemType = "image" } 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: req.Title, Description: req.Description, LinkURL: &req.URL, ItemType: itemType, EmbedProvider: embedProvider, EmbedVideoID: embedVideoID, EmbedHTML: embedHTML, }) if err != nil { return err } // Download and store image if available var imageURL string if req.ImageURL != nil { imageURL = *req.ImageURL } // For embeds, fetch thumbnail if itemType == "embed" && embedProvider != nil { if videoInfo, err := embed.Detect(ctx, req.URL); err == nil && videoInfo != nil { imageURL = videoInfo.ThumbnailURL } } if imageURL != "" { imgData, contentType, err := opengraph.DownloadImage(ctx, imageURL) if err != nil { rc.Logger.Warn("failed to download image", "url", imageURL, "error", err) } else { _, err = media.QCreate(ctx, rc.DB, media.CreateParams{ ItemID: it.ID, MediaType: "thumbnail", ContentType: contentType, Data: imgData, SourceURL: &imageURL, }) if err != nil { rc.Logger.Warn("failed to store image", "error", 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) } // 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 var titlePtr, descPtr *string if title != "" { titlePtr = &title } if description != "" { descPtr = &description } it, err := item.QCreate(ctx, rc.DB, item.CreateParams{ Title: titlePtr, Description: descPtr, 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: req.Source, Description: &req.Text, LinkURL: 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 == nil || *it.LinkURL == "" { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "item has no link URL"}) } meta, err := fetchURLMetadata(ctx, *it.LinkURL) if err != nil { return writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("failed to fetch: %v", err)}) } // Update title and description var titlePtr, descPtr *string if meta.Title != "" { titlePtr = &meta.Title } if meta.Description != "" { descPtr = &meta.Description } item.QUpdate(ctx, rc.DB, it.ID, item.UpdateParams{ Title: titlePtr, Description: descPtr, LinkURL: it.LinkURL, }) // Download and replace thumbnail if meta.ImageURL != "" { // Delete existing media for this item media.QDeleteByItemID(ctx, rc.DB, it.ID) imgData, contentType, err := opengraph.DownloadImage(ctx, meta.ImageURL) if err != nil { rc.Logger.Warn("failed to download image during refresh", "url", meta.ImageURL, "error", err) } else { _, err = media.QCreate(ctx, rc.DB, media.CreateParams{ ItemID: it.ID, MediaType: "thumbnail", ContentType: contentType, Data: imgData, SourceURL: &meta.ImageURL, }) if err != nil { rc.Logger.Warn("failed to store refreshed image", "error", err) } } } // 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) }