package handlers import ( "context" "fmt" "net/http" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type AttachmentDTO struct { ID string `json:"id"` Kind string `json:"kind"` URL string `json:"url"` Mime string `json:"mime"` Filename string `json:"filename"` SizeBytes int64 `json:"sizeBytes"` Width *int `json:"width,omitempty"` Height *int `json:"height,omitempty"` DurationSec *int `json:"durationSec,omitempty"` PosterURL string `json:"posterUrl,omitempty"` ThumbnailURL string `json:"thumbnailUrl,omitempty"` } type PostDTO struct { ID string `json:"id"` PostType string `json:"postType"` CategoryID int `json:"categoryId,omitempty"` CategorySlug string `json:"categorySlug,omitempty"` Language string `json:"language"` // UI locale from ?lang= (matches text selection) SourceLanguage string `json:"sourceLanguage,omitempty"` // DB source metadata (admin input language) Text string `json:"text,omitempty"` Localizations map[string]postLocalePayload `json:"localizations"` Attachments []AttachmentDTO `json:"attachments"` IsRecommended bool `json:"isRecommended"` PublishedAt string `json:"publishedAt"` UpdatedAt string `json:"updatedAt"` CreatedAt string `json:"createdAt,omitempty"` Tags []string `json:"tags,omitempty"` } type PostListResponse struct { Items []PostDTO `json:"items"` NextCursor string `json:"nextCursor,omitempty"` } type attachmentInput struct { ID string `json:"id"` Kind string `json:"kind"` URL string `json:"url"` Mime string `json:"mime"` Filename string `json:"filename"` SizeBytes int64 `json:"sizeBytes"` Width *int `json:"width"` Height *int `json:"height"` DurationSec *int `json:"durationSec"` PosterURL string `json:"posterUrl"` ThumbnailURL string `json:"thumbnailUrl"` SortOrder int `json:"sortOrder"` } type adminPost struct { ID string `json:"id"` PostType string `json:"postType"` CategoryID int `json:"categoryId,omitempty"` CategorySlug string `json:"categorySlug,omitempty"` Language string `json:"language"` Text string `json:"text"` Attachments []attachmentInput `json:"attachments"` IsPublic bool `json:"isPublic"` IsRecommended bool `json:"isRecommended"` SortOrder int `json:"sortOrder"` Status string `json:"status"` PublishedAt *string `json:"publishedAt,omitempty"` CreatedAt string `json:"createdAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` Tags []string `json:"tags,omitempty"` Localizations map[string]postLocalePayload `json:"localizations,omitempty"` ViewCount int `json:"viewCount,omitempty"` DownloadCount int `json:"downloadCount,omitempty"` } const publicPostWhere = `p.status = 'published' AND p.is_public = TRUE AND (p.published_at IS NULL OR p.published_at <= NOW())` func parsePublishedAtPtr(s *string) (*time.Time, error) { if s == nil || strings.TrimSpace(*s) == "" { return nil, nil } t, err := time.Parse(time.RFC3339, strings.TrimSpace(*s)) if err != nil { t, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(*s)) } if err != nil { return nil, err } utc := t.UTC() return &utc, nil } func resolvePostPublishedAt(pubIn *string, status string, existing *time.Time, isCreate bool) (*time.Time, error) { if pubIn != nil && strings.TrimSpace(*pubIn) != "" { return parsePublishedAtPtr(pubIn) } if status == "published" { if isCreate || existing == nil { t := time.Now().UTC() return &t, nil } return existing, nil } return existing, nil } func formatTimePtr(t *time.Time) string { if t == nil { return "" } return t.UTC().Format(time.RFC3339) } func encodePostCursor(pub time.Time, id uuid.UUID) string { return pub.UTC().Format(time.RFC3339Nano) + "|" + id.String() } func decodePostCursor(cursor string) (time.Time, uuid.UUID, error) { parts := strings.SplitN(cursor, "|", 2) if len(parts) != 2 { return time.Time{}, uuid.Nil, fmt.Errorf("bad cursor") } t, err := time.Parse(time.RFC3339Nano, parts[0]) if err != nil { t, err = time.Parse(time.RFC3339, parts[0]) } if err != nil { return time.Time{}, uuid.Nil, err } id, err := uuid.Parse(parts[1]) if err != nil { return time.Time{}, uuid.Nil, err } return t.UTC(), id, nil } func postTypeFilterSQL(typ string, args *[]any) string { raw := strings.ToLower(strings.TrimSpace(typ)) if raw == "" || raw == "all" { return "" } typ = normalizePostType(typ) *args = append(*args, typ) n := len(*args) // Primary: admin-selected post_type; legacy rows fall back via OR attachment heuristics. switch typ { case "image": return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS ( SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'image')))`, n) case "video": return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS ( SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.kind = 'video' OR pa.mime LIKE 'video/%%'))))`, n) case "music": return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS ( SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.mime LIKE 'audio/%%' OR pa.filename ILIKE '%%.mp3' OR pa.filename ILIKE '%%.wav' OR pa.filename ILIKE '%%.m4a'))))`, n) case "pdf": return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS ( SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%%pdf%%' OR pa.filename ILIKE '%%.pdf')))`, n) case "ppt": return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS ( SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%%presentation%%' OR pa.filename ILIKE '%%.ppt%%' OR pa.filename ILIKE '%%.pptx%%')))`, n) case "archive": return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS ( SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.filename ILIKE '%%.zip%%' OR pa.filename ILIKE '%%.rar%%' OR pa.filename ILIKE '%%.7z%%')))`, n) case "text": return fmt.Sprintf(` AND p.post_type = $%d`, n) case "link": return fmt.Sprintf(` AND (p.post_type = $%d OR (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ms ~* 'https?://'))`, n) default: return fmt.Sprintf(` AND p.post_type = $%d`, n) } } func normalizePostLangFilter(lang string) string { lang = strings.TrimSpace(lang) if lang == "" { return "" } return translateNormalizePostLang(lang) } func translateNormalizePostLang(lang string) string { lang = strings.ToLower(lang) switch { case strings.HasPrefix(lang, "zh"): return "zh" case strings.HasPrefix(lang, "en"): return "en" case strings.HasPrefix(lang, "ja"): return "ja" case strings.HasPrefix(lang, "ko"): return "ko" case strings.HasPrefix(lang, "vi"): return "vi" case lang == "id", strings.HasPrefix(lang, "in"): return "id" case strings.HasPrefix(lang, "ms"): return "ms" default: return lang } } func validateAdminPost(ap *adminPost) error { ap.PostType = inferPostTypeFromContent(ap.Text, ap.Attachments) if !validPostTypes[ap.PostType] { ap.PostType = "text" } if len(ap.Attachments) > maxPostAttachments { return fmt.Errorf("too many attachments (max %d)", maxPostAttachments) } hasText := strings.TrimSpace(ap.Text) != "" hasAtt := len(ap.Attachments) > 0 if ap.Status == "published" && !hasText && !hasAtt { return fmt.Errorf("published post requires text and/or at least one attachment") } if ap.Status == "published" && ap.CategoryID <= 0 { return fmt.Errorf("published post requires categoryId") } for i, a := range ap.Attachments { if strings.TrimSpace(a.URL) == "" { return fmt.Errorf("attachment %d: url required", i) } k := strings.TrimSpace(a.Kind) if k != "image" && k != "video" && k != "document" { return fmt.Errorf("attachment %d: invalid kind", i) } } if ap.Status == "" { ap.Status = "draft" } normalized, err := normalizeAdminPostTags(ap.Tags) if err != nil { return err } ap.Tags = normalized return nil } func loadAttachmentsByPostIDs(ctx context.Context, pool *pgxpool.Pool, ids []uuid.UUID) (map[uuid.UUID][]AttachmentDTO, error) { out := make(map[uuid.UUID][]AttachmentDTO) if len(ids) == 0 { return out, nil } rows, err := pool.Query(ctx, ` SELECT id, post_id, kind, url, mime, filename, size_bytes, width, height, duration_sec, COALESCE(poster_url,''), COALESCE(thumbnail_url,''), sort_order FROM post_attachments WHERE post_id = ANY($1) ORDER BY post_id, sort_order ASC, id ASC`, ids) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var aid, pid uuid.UUID var a AttachmentDTO var kind, url, mime, filename string var size int64 var w, h, dur *int var poster, thumb string var sort int if err := rows.Scan(&aid, &pid, &kind, &url, &mime, &filename, &size, &w, &h, &dur, &poster, &thumb, &sort); err != nil { return nil, err } a.ID = aid.String() a.Kind = kind a.URL = url a.Mime = mime a.Filename = filename a.SizeBytes = size a.Width = w a.Height = h a.DurationSec = dur a.PosterURL = poster a.ThumbnailURL = thumb out[pid] = append(out[pid], a) } return out, rows.Err() } func loadPostTagNames(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, lang string) ([]string, error) { rows, err := pool.Query(ctx, ` SELECT t.name, COALESCE(t.name_en,''), COALESCE(t.name_ja,''), COALESCE(t.name_ko,''), COALESCE(t.name_vi,''), COALESCE(t.name_id,''), COALESCE(t.name_ms,''), t.slug FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = $1 ORDER BY t.slug`, postID) if err != nil { return nil, err } defer rows.Close() var names []string for rows.Next() { var name, en, ja, ko, vi, id, ms, slug string if err := rows.Scan(&name, &en, &ja, &ko, &vi, &id, &ms, &slug); err != nil { return nil, err } names = append(names, tagDisplayName(name, en, ja, ko, vi, id, ms, lang)) } return names, rows.Err() } func replacePostTags(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, tagSlugs []string) error { _, err := pool.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID) if err != nil { return err } for _, slug := range tagSlugs { slug = strings.TrimSpace(slug) if slug == "" { continue } var tid int err := pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid) if err != nil { continue } _, _ = pool.Exec(ctx, `INSERT INTO post_tags (post_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, postID, tid) } return nil } func replacePostAttachments(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, inputs []attachmentInput) error { _, err := pool.Exec(ctx, `DELETE FROM post_attachments WHERE post_id = $1`, postID) if err != nil { return err } for i, in := range inputs { kind := strings.TrimSpace(in.Kind) if kind == "" { kind = classifyAttachmentKind(in.Mime, in.Filename) } sort := in.SortOrder if sort == 0 { sort = i } _, err := pool.Exec(ctx, ` INSERT INTO post_attachments (post_id, kind, url, mime, filename, size_bytes, width, height, duration_sec, poster_url, thumbnail_url, sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, postID, kind, strings.TrimSpace(in.URL), nullIfEmptyStr(in.Mime, "application/octet-stream"), nullIfEmptyStr(in.Filename, "file"), in.SizeBytes, in.Width, in.Height, in.DurationSec, nullIfEmpty(in.PosterURL), nullIfEmpty(in.ThumbnailURL), sort) if err != nil { return err } } return nil } func classifyAttachmentKind(mime, filename string) string { m := strings.ToLower(mime) if strings.HasPrefix(m, "image/") { return "image" } if strings.HasPrefix(m, "video/") { return "video" } fn := strings.ToLower(filename) if strings.HasSuffix(fn, ".jpg") || strings.HasSuffix(fn, ".jpeg") || strings.HasSuffix(fn, ".png") || strings.HasSuffix(fn, ".gif") || strings.HasSuffix(fn, ".webp") { return "image" } if strings.HasSuffix(fn, ".mp4") || strings.HasSuffix(fn, ".mov") || strings.HasSuffix(fn, ".webm") { return "video" } return "document" } func postCategoryIDArg(id int) any { if id <= 0 { return nil } return id } func nullIfEmptyStr(s, def string) string { if strings.TrimSpace(s) == "" { return def } return s } func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) { var dto PostDTO var texts postTextI18n var id uuid.UUID var catID *int var slug *string var sourceLang, postType string var pub, updated, created *time.Time err := row.Scan( &id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs, &sourceLang, &postType, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created, ) if err != nil { return dto, texts, err } dto.ID = id.String() dto.PostType = normalizePostType(postType) if catID != nil { dto.CategoryID = *catID } if slug != nil { dto.CategorySlug = *slug } dto.SourceLanguage = sourceLang dto.Language = requestLangCode(r) dto.Text = texts.pick(r) dto.Localizations = texts.toLocalizations() if pub != nil { dto.PublishedAt = pub.UTC().Format(time.RFC3339) } else if created != nil { dto.PublishedAt = created.UTC().Format(time.RFC3339) } if updated != nil { dto.UpdatedAt = updated.UTC().Format(time.RFC3339) } if created != nil { dto.CreatedAt = created.UTC().Format(time.RFC3339) } return dto, texts, nil } func postSelectBase() string { return ` SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), COALESCE(p.post_type,'text'), p.category_id, c.slug, p.is_recommended, p.published_at, p.updated_at, p.created_at FROM posts p LEFT JOIN categories c ON c.id = p.category_id` } func postsFromClause() string { return `FROM posts p LEFT JOIN categories c ON c.id = p.category_id WHERE 1=1` } func postLimitDef(r *http.Request, def, max int) int { limit := atoiDef(r.URL.Query().Get("limit"), def) if limit > max { limit = max } return limit } func postLanguageFilterSQL(lang string, args *[]any) string { norm := normalizePostLangFilter(lang) if norm == "" { return "" } *args = append(*args, norm) return fmt.Sprintf(` AND p.language = $%d`, len(*args)) }