2026-05-25 16:45:33 +08:00
|
|
|
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"`
|
2026-05-25 23:43:55 +08:00
|
|
|
PostType string `json:"postType"`
|
|
|
|
|
CategoryID int `json:"categoryId,omitempty"`
|
|
|
|
|
CategorySlug string `json:"categorySlug,omitempty"`
|
2026-05-26 11:12:22 +08:00
|
|
|
Language string `json:"language"` // UI locale from ?lang= (matches text selection)
|
|
|
|
|
SourceLanguage string `json:"sourceLanguage,omitempty"` // DB source metadata (admin input language)
|
2026-05-25 23:43:55 +08:00
|
|
|
Text string `json:"text,omitempty"`
|
|
|
|
|
Localizations map[string]postLocalePayload `json:"localizations"`
|
|
|
|
|
Attachments []AttachmentDTO `json:"attachments"`
|
2026-05-25 16:45:33 +08:00
|
|
|
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"`
|
2026-05-25 23:43:55 +08:00
|
|
|
PostType string `json:"postType"`
|
|
|
|
|
CategoryID int `json:"categoryId,omitempty"`
|
2026-05-26 09:01:25 +08:00
|
|
|
CategorySlug string `json:"categorySlug,omitempty"`
|
2026-05-25 16:45:33 +08:00
|
|
|
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 {
|
2026-05-25 23:43:55 +08:00
|
|
|
raw := strings.ToLower(strings.TrimSpace(typ))
|
|
|
|
|
if raw == "" || raw == "all" {
|
2026-05-25 16:45:33 +08:00
|
|
|
return ""
|
|
|
|
|
}
|
2026-05-25 23:43:55 +08:00
|
|
|
typ = normalizePostType(typ)
|
|
|
|
|
*args = append(*args, typ)
|
|
|
|
|
n := len(*args)
|
|
|
|
|
// Primary: admin-selected post_type; legacy rows fall back via OR attachment heuristics.
|
2026-05-25 16:45:33 +08:00
|
|
|
switch typ {
|
|
|
|
|
case "image":
|
2026-05-25 23:43:55 +08:00
|
|
|
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)
|
2026-05-25 16:45:33 +08:00
|
|
|
case "video":
|
2026-05-25 23:43:55 +08:00
|
|
|
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)
|
2026-05-25 16:45:33 +08:00
|
|
|
case "pdf":
|
2026-05-25 23:43:55 +08:00
|
|
|
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)
|
2026-05-25 16:45:33 +08:00
|
|
|
case "ppt":
|
2026-05-25 23:43:55 +08:00
|
|
|
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)
|
2026-05-25 16:45:33 +08:00
|
|
|
case "archive":
|
2026-05-25 23:43:55 +08:00
|
|
|
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)
|
2026-05-25 16:45:33 +08:00
|
|
|
case "text":
|
2026-05-25 23:43:55 +08:00
|
|
|
return fmt.Sprintf(` AND p.post_type = $%d`, n)
|
2026-05-25 16:45:33 +08:00
|
|
|
case "link":
|
2026-05-25 23:43:55 +08:00
|
|
|
return fmt.Sprintf(` AND (p.post_type = $%d OR (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ms ~* 'https?://'))`, n)
|
2026-05-25 16:45:33 +08:00
|
|
|
default:
|
2026-05-25 23:43:55 +08:00
|
|
|
return fmt.Sprintf(` AND p.post_type = $%d`, n)
|
2026-05-25 16:45:33 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-26 11:12:22 +08:00
|
|
|
ap.PostType = inferPostTypeFromContent(ap.Text, ap.Attachments)
|
|
|
|
|
if !validPostTypes[ap.PostType] {
|
|
|
|
|
ap.PostType = "text"
|
2026-05-25 16:45:33 +08:00
|
|
|
}
|
|
|
|
|
if len(ap.Attachments) > maxPostAttachments {
|
|
|
|
|
return fmt.Errorf("too many attachments (max %d)", maxPostAttachments)
|
|
|
|
|
}
|
|
|
|
|
hasText := strings.TrimSpace(ap.Text) != ""
|
|
|
|
|
hasAtt := len(ap.Attachments) > 0
|
2026-05-25 23:43:55 +08:00
|
|
|
if ap.Status == "published" && !hasText && !hasAtt {
|
|
|
|
|
return fmt.Errorf("published post requires text and/or at least one attachment")
|
2026-05-25 16:45:33 +08:00
|
|
|
}
|
2026-05-26 09:01:25 +08:00
|
|
|
if ap.Status == "published" && ap.CategoryID <= 0 {
|
|
|
|
|
return fmt.Errorf("published post requires categoryId")
|
|
|
|
|
}
|
2026-05-25 16:45:33 +08:00
|
|
|
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"
|
|
|
|
|
}
|
2026-05-26 12:08:39 +08:00
|
|
|
normalized, err := normalizeAdminPostTags(ap.Tags)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
ap.Tags = normalized
|
2026-05-25 16:45:33 +08:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 12:08:39 +08:00
|
|
|
func loadPostTagNames(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, lang string) ([]string, error) {
|
2026-05-25 16:45:33 +08:00
|
|
|
rows, err := pool.Query(ctx, `
|
2026-05-26 12:08:39 +08:00
|
|
|
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)
|
2026-05-25 16:45:33 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
var names []string
|
|
|
|
|
for rows.Next() {
|
2026-05-26 12:08:39 +08:00
|
|
|
var name, en, ja, ko, vi, id, ms, slug string
|
|
|
|
|
if err := rows.Scan(&name, &en, &ja, &ko, &vi, &id, &ms, &slug); err != nil {
|
2026-05-25 16:45:33 +08:00
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-05-26 12:08:39 +08:00
|
|
|
names = append(names, tagDisplayName(name, en, ja, ko, vi, id, ms, lang))
|
2026-05-25 16:45:33 +08:00
|
|
|
}
|
|
|
|
|
return names, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 12:08:39 +08:00
|
|
|
func replacePostTags(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, tagSlugs []string) error {
|
2026-05-25 16:45:33 +08:00
|
|
|
_, err := pool.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-05-26 12:08:39 +08:00
|
|
|
for _, slug := range tagSlugs {
|
|
|
|
|
slug = strings.TrimSpace(slug)
|
|
|
|
|
if slug == "" {
|
2026-05-25 16:45:33 +08:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var tid int
|
2026-05-26 12:08:39 +08:00
|
|
|
err := pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid)
|
2026-05-25 16:45:33 +08:00
|
|
|
if err != nil {
|
2026-05-26 12:08:39 +08:00
|
|
|
continue
|
2026-05-25 16:45:33 +08:00
|
|
|
}
|
|
|
|
|
_, _ = 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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 09:01:25 +08:00
|
|
|
func postCategoryIDArg(id int) any {
|
|
|
|
|
if id <= 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return id
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:45:33 +08:00
|
|
|
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
|
2026-05-25 23:43:55 +08:00
|
|
|
var catID *int
|
|
|
|
|
var slug *string
|
2026-05-26 11:12:22 +08:00
|
|
|
var sourceLang, postType string
|
2026-05-25 16:45:33 +08:00
|
|
|
var pub, updated, created *time.Time
|
|
|
|
|
err := row.Scan(
|
|
|
|
|
&id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs,
|
2026-05-26 11:12:22 +08:00
|
|
|
&sourceLang, &postType, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created,
|
2026-05-25 16:45:33 +08:00
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return dto, texts, err
|
|
|
|
|
}
|
|
|
|
|
dto.ID = id.String()
|
2026-05-25 23:43:55 +08:00
|
|
|
dto.PostType = normalizePostType(postType)
|
|
|
|
|
if catID != nil {
|
|
|
|
|
dto.CategoryID = *catID
|
|
|
|
|
}
|
|
|
|
|
if slug != nil {
|
|
|
|
|
dto.CategorySlug = *slug
|
|
|
|
|
}
|
2026-05-26 11:12:22 +08:00
|
|
|
dto.SourceLanguage = sourceLang
|
|
|
|
|
dto.Language = requestLangCode(r)
|
2026-05-25 16:45:33 +08:00
|
|
|
dto.Text = texts.pick(r)
|
2026-05-25 23:43:55 +08:00
|
|
|
dto.Localizations = texts.toLocalizations()
|
2026-05-25 16:45:33 +08:00
|
|
|
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 `
|
2026-05-25 23:43:55 +08:00
|
|
|
SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), COALESCE(p.post_type,'text'),
|
|
|
|
|
p.category_id, c.slug,
|
2026-05-25 16:45:33 +08:00
|
|
|
p.is_recommended, p.published_at, p.updated_at, p.created_at
|
2026-05-25 23:43:55 +08:00
|
|
|
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`
|
2026-05-25 16:45:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
}
|