Initial commit: Lookbook personal collection app
Pinterest-like app for saving images, videos, quotes, and embeds. Features: - Go backend with PostgreSQL, SSR templates - Console-based admin auth (login/logout via browser console) - Item types: images, videos (ffmpeg transcoding), quotes, embeds - Media stored as BLOBs in PostgreSQL - OpenGraph metadata extraction for links - Embed detection for YouTube, Vimeo, Twitter/X - Masonry grid layout, item detail pages - Tag system with filtering - Refresh metadata endpoint with change warnings - Replace media endpoint for updating item images/videos
This commit is contained in:
commit
cdcc5b5293
45 changed files with 4634 additions and 0 deletions
577
internal/handlers/api_upload.go
Normal file
577
internal/handlers/api_upload.go
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue