From b2879720de39b790d03f646752bddd49c192e224 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 25 May 2026 16:45:33 +0800 Subject: [PATCH] 1 --- cmd/server/main.go | 12 + go.mod | 13 +- go.sum | 10 +- internal/handlers/category_i18n.go | 35 ++ internal/handlers/category_text.go | 36 ++ internal/handlers/post_schema.go | 61 ++++ internal/handlers/post_text.go | 74 ++++ internal/handlers/posts_common.go | 418 ++++++++++++++++++++++ internal/handlers/posts_public.go | 215 +++++++++++ internal/handlers/public.go | 73 ++-- internal/handlers/resource_schema.go | 47 ++- internal/handlers/resource_text.go | 84 ++++- internal/handlers/upload.go | 33 +- migrations/006_resource_seven_locales.sql | 32 ++ migrations/007_category_seven_locales.sql | 21 ++ migrations/008_posts.sql | 52 +++ 16 files changed, 1126 insertions(+), 90 deletions(-) create mode 100644 internal/handlers/category_i18n.go create mode 100644 internal/handlers/category_text.go create mode 100644 internal/handlers/post_schema.go create mode 100644 internal/handlers/post_text.go create mode 100644 internal/handlers/posts_common.go create mode 100644 internal/handlers/posts_public.go create mode 100644 migrations/006_resource_seven_locales.sql create mode 100644 migrations/007_category_seven_locales.sql create mode 100644 migrations/008_posts.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 286326a..9a2611c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -42,6 +42,12 @@ func main() { if err := handlers.EnsureResourceI18nColumns(ctx, pool); err != nil { log.Fatalf("resources i18n columns: %v", err) } + if err := handlers.EnsureCategoryI18nColumns(ctx, pool); err != nil { + log.Fatalf("categories i18n columns: %v", err) + } + if err := handlers.EnsurePostsSchema(ctx, pool); err != nil { + log.Fatalf("posts schema: %v", err) + } if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil { log.Fatal(err) @@ -113,6 +119,12 @@ func main() { }) r.Get("/categories", handlers.ListCategories) + r.Get("/posts", handlers.ListPosts) + r.Get("/posts/recommended", handlers.ListPostsRecommended) + r.Get("/posts/latest", handlers.ListPostsLatest) + r.Get("/posts/search", handlers.SearchPosts) + r.Get("/posts/{id}", handlers.GetPost) + r.Post("/posts/{id}/attachments/{aid}/download", handlers.PostAttachmentDownload) r.Get("/resources", handlers.ListResources) r.Get("/resources/recommended", handlers.ListRecommended) r.Get("/resources/latest", handlers.ListLatest) diff --git a/go.mod b/go.mod index fef0b83..be4e625 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,11 @@ module github.com/arkie/ark-database -go 1.24.0 +go 1.25.0 require ( + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 github.com/ethereum/go-ethereum v1.17.2 github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/cors v1.2.1 @@ -10,13 +13,12 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.1 golang.org/x/crypto v0.44.0 + golang.org/x/image v0.41.0 ) require ( github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect @@ -26,7 +28,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect @@ -42,7 +43,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/supranational/blst v0.3.16 // indirect - golang.org/x/sync v0.18.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 2b935af..0aa6812 100644 --- a/go.sum +++ b/go.sum @@ -110,12 +110,14 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/handlers/category_i18n.go b/internal/handlers/category_i18n.go new file mode 100644 index 0000000..cac9407 --- /dev/null +++ b/internal/handlers/category_i18n.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// EnsureCategoryI18nColumns adds per-locale category name/description columns. +func EnsureCategoryI18nColumns(ctx context.Context, pool *pgxpool.Pool) error { + _, err := pool.Exec(ctx, ` + ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_zh TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_en TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ja TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ko TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_vi TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_id TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ms TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_zh TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_en TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ja TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ko TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_vi TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_id TEXT; + ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ms TEXT`) + if err != nil { + return err + } + _, err = pool.Exec(ctx, ` + UPDATE categories SET name_zh = COALESCE(NULLIF(name_zh, ''), NULLIF(name_zh_cn, ''), name_zh_tw) + WHERE COALESCE(name_zh, '') = ''; + UPDATE categories SET description_zh = COALESCE(description_zh, description_zh_tw) + WHERE description_zh IS NULL`) + return err +} diff --git a/internal/handlers/category_text.go b/internal/handlers/category_text.go new file mode 100644 index 0000000..b624209 --- /dev/null +++ b/internal/handlers/category_text.go @@ -0,0 +1,36 @@ +package handlers + +import "net/http" + +const categoryNameColsSQL = ` + COALESCE(c.name_zh,''), COALESCE(c.name_en,''), COALESCE(c.name_ja,''), + COALESCE(c.name_ko,''), COALESCE(c.name_vi,''), COALESCE(c.name_id,''), COALESCE(c.name_ms,'')` + +const categoryDescColsSQL = ` + COALESCE(c.description_zh,''), COALESCE(c.description_en,''), COALESCE(c.description_ja,''), + COALESCE(c.description_ko,''), COALESCE(c.description_vi,''), COALESCE(c.description_id,''), COALESCE(c.description_ms,'')` + +const categoryI18nColsSQL = categoryNameColsSQL + `, ` + categoryDescColsSQL + +type categoryTextI18n struct { + NameZh, NameEn, NameJa, NameKo, NameVi, NameId, NameMs string + DescZh, DescEn, DescJa, DescKo, DescVi, DescId, DescMs string +} + +func (t categoryTextI18n) pickName(r *http.Request) string { + return pickLangField(r, t.NameZh, t.NameEn, t.NameJa, t.NameKo, t.NameVi, t.NameId, t.NameMs) +} + +func (t categoryTextI18n) pickDesc(r *http.Request) string { + return pickLangField(r, t.DescZh, t.DescEn, t.DescJa, t.DescKo, t.DescVi, t.DescId, t.DescMs) +} + +func scanCategoryTextI18n( + nameZh, nameEn, nameJa, nameKo, nameVi, nameId, nameMs string, + descZh, descEn, descJa, descKo, descVi, descId, descMs string, +) categoryTextI18n { + return categoryTextI18n{ + NameZh: nameZh, NameEn: nameEn, NameJa: nameJa, NameKo: nameKo, NameVi: nameVi, NameId: nameId, NameMs: nameMs, + DescZh: descZh, DescEn: descEn, DescJa: descJa, DescKo: descKo, DescVi: descVi, DescId: descId, DescMs: descMs, + } +} diff --git a/internal/handlers/post_schema.go b/internal/handlers/post_schema.go new file mode 100644 index 0000000..bf77dc7 --- /dev/null +++ b/internal/handlers/post_schema.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// EnsurePostsSchema creates posts, post_attachments, and post_tags tables. +func EnsurePostsSchema(ctx context.Context, pool *pgxpool.Pool) error { + _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id INT NOT NULL REFERENCES categories(id), + language TEXT NOT NULL DEFAULT 'zh', + text_zh TEXT, + text_en TEXT, + text_ja TEXT, + text_ko TEXT, + text_vi TEXT, + text_id TEXT, + text_ms TEXT, + is_public BOOLEAN NOT NULL DEFAULT TRUE, + is_recommended BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'draft', + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + view_count INT NOT NULL DEFAULT 0, + download_count INT NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category_id); + CREATE INDEX IF NOT EXISTS idx_posts_status_public ON posts(status, is_public); + CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published_at DESC NULLS LAST, id DESC); + CREATE INDEX IF NOT EXISTS idx_posts_recommended ON posts(is_recommended) WHERE is_recommended = TRUE; + + CREATE TABLE IF NOT EXISTS post_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + url TEXT NOT NULL, + mime TEXT NOT NULL DEFAULT 'application/octet-stream', + filename TEXT NOT NULL DEFAULT '', + size_bytes BIGINT NOT NULL DEFAULT 0, + width INT, + height INT, + duration_sec INT, + poster_url TEXT, + thumbnail_url TEXT, + sort_order INT NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_post_attachments_post ON post_attachments(post_id, sort_order ASC); + + CREATE TABLE IF NOT EXISTS post_tags ( + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (post_id, tag_id) + )`) + return err +} diff --git a/internal/handlers/post_text.go b/internal/handlers/post_text.go new file mode 100644 index 0000000..67e3bf1 --- /dev/null +++ b/internal/handlers/post_text.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "net/http" + "regexp" + "strings" +) + +const postI18nColsSQL = ` + COALESCE(p.text_zh,''), COALESCE(p.text_en,''), COALESCE(p.text_ja,''), + COALESCE(p.text_ko,''), COALESCE(p.text_vi,''), COALESCE(p.text_id,''), COALESCE(p.text_ms,'')` + +const maxPostTextLen = 32768 +const maxPostAttachments = 20 + +var postLinkRe = regexp.MustCompile(`https?://`) + +type postTextI18n struct { + TextZh, TextEn, TextJa, TextKo, TextVi, TextId, TextMs string +} + +type postLocalePayload struct { + Text string `json:"text"` +} + +func (t postTextI18n) pick(r *http.Request) string { + return pickLangField(r, t.TextZh, t.TextEn, t.TextJa, t.TextKo, t.TextVi, t.TextId, t.TextMs) +} + +func (t postTextI18n) anyNonEmpty() bool { + return strings.TrimSpace(t.TextZh) != "" || + strings.TrimSpace(t.TextEn) != "" || + strings.TrimSpace(t.TextJa) != "" || + strings.TrimSpace(t.TextKo) != "" || + strings.TrimSpace(t.TextVi) != "" || + strings.TrimSpace(t.TextId) != "" || + strings.TrimSpace(t.TextMs) != "" +} + +func (t postTextI18n) legacyPrimary() string { + if strings.TrimSpace(t.TextZh) != "" { + return strings.TrimSpace(t.TextZh) + } + if strings.TrimSpace(t.TextEn) != "" { + return strings.TrimSpace(t.TextEn) + } + return "" +} + +func scanPostTextI18n(zh, en, ja, ko, vi, id, ms string) postTextI18n { + return postTextI18n{TextZh: zh, TextEn: en, TextJa: ja, TextKo: ko, TextVi: vi, TextId: id, TextMs: ms} +} + +func postTextHasLink(text string) bool { + return postLinkRe.MatchString(text) +} + +func truncatePostTexts(t postTextI18n) postTextI18n { + tr := func(s string) string { + s = strings.TrimSpace(s) + if len(s) > maxPostTextLen { + return s[:maxPostTextLen] + } + return s + } + t.TextZh = tr(t.TextZh) + t.TextEn = tr(t.TextEn) + t.TextJa = tr(t.TextJa) + t.TextKo = tr(t.TextKo) + t.TextVi = tr(t.TextVi) + t.TextId = tr(t.TextId) + t.TextMs = tr(t.TextMs) + return t +} diff --git a/internal/handlers/posts_common.go b/internal/handlers/posts_common.go new file mode 100644 index 0000000..5d6f429 --- /dev/null +++ b/internal/handlers/posts_common.go @@ -0,0 +1,418 @@ +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"` + CategoryID int `json:"categoryId"` + CategorySlug string `json:"categorySlug"` + Language string `json:"language"` + Text string `json:"text,omitempty"` + 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"` + CategoryID int `json:"categoryId"` + 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 { + typ = strings.ToLower(strings.TrimSpace(typ)) + if typ == "" || typ == "all" { + return "" + } + switch typ { + case "image": + *args = append(*args, "image") + n := len(*args) + return fmt.Sprintf(` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = $%d)`, n) + case "video": + return ` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.kind = 'video' OR pa.mime LIKE 'video/%'))` + case "pdf": + return ` AND 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'))` + case "ppt": + return ` AND 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%' OR pa.filename ILIKE '%.key%'))` + case "archive": + return ` AND 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%'))` + case "text": + return ` AND NOT EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id) + AND (COALESCE(p.text_zh,'') <> '' OR COALESCE(p.text_en,'') <> '' OR COALESCE(p.text_ja,'') <> '' OR COALESCE(p.text_ko,'') <> '' OR COALESCE(p.text_vi,'') <> '' OR COALESCE(p.text_id,'') <> '' OR COALESCE(p.text_ms,'') <> '')` + case "link": + return ` AND (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ja ~* 'https?://' OR p.text_ko ~* 'https?://' OR p.text_vi ~* 'https?://' OR p.text_id ~* 'https?://' OR p.text_ms ~* 'https?://')` + default: + return "" + } +} + +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 { + if ap.CategoryID <= 0 { + return fmt.Errorf("categoryId required") + } + 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 !hasText && !hasAtt { + return fmt.Errorf("post requires text and/or at least one attachment") + } + 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" + } + 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) ([]string, error) { + rows, err := pool.Query(ctx, ` + SELECT t.name FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = $1 ORDER BY t.name`, postID) + if err != nil { + return nil, err + } + defer rows.Close() + var names []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + return nil, err + } + names = append(names, n) + } + return names, rows.Err() +} + +func replacePostTags(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, tags []string) error { + _, err := pool.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID) + if err != nil { + return err + } + for _, t := range tags { + t = strings.TrimSpace(t) + if t == "" { + continue + } + var tid int + slug := tagSlug(t) + err := pool.QueryRow(ctx, `INSERT INTO tags (name, slug) VALUES ($1, $2) + ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name RETURNING id`, t, slug).Scan(&tid) + if err != nil { + _ = pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid) + } + _, _ = 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 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 lang 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, + &lang, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created, + ) + if err != nil { + return dto, texts, err + } + dto.ID = id.String() + dto.CategoryID = catID + dto.CategorySlug = slug + dto.Language = lang + dto.Text = texts.pick(r) + 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'), p.category_id, c.slug, + p.is_recommended, p.published_at, p.updated_at, p.created_at + FROM posts p JOIN categories c ON c.id = p.category_id` +} + +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)) +} diff --git a/internal/handlers/posts_public.go b/internal/handlers/posts_public.go new file mode 100644 index 0000000..7ed03d6 --- /dev/null +++ b/internal/handlers/posts_public.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func ListPosts(w http.ResponseWriter, r *http.Request) { + listPostsQuery(w, r, false) +} + +func ListPostsRecommended(w http.ResponseWriter, r *http.Request) { + pool := poolFrom(r) + limit := postLimitDef(r, 12, 50) + rows, err := pool.Query(r.Context(), postSelectBase()+` + WHERE `+publicPostWhere+` AND p.is_recommended = TRUE + ORDER BY p.sort_order ASC, p.published_at DESC NULLS LAST, p.id DESC + LIMIT $1`, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + items, err := collectPostRows(r, rows) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"items": items}) +} + +func ListPostsLatest(w http.ResponseWriter, r *http.Request) { + pool := poolFrom(r) + limit := postLimitDef(r, 8, 50) + rows, err := pool.Query(r.Context(), postSelectBase()+` + WHERE `+publicPostWhere+` + ORDER BY p.published_at DESC NULLS LAST, p.id DESC + LIMIT $1`, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + items, err := collectPostRows(r, rows) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"items": items}) +} + +func SearchPosts(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeJSON(w, PostListResponse{Items: []PostDTO{}}) + return + } + listPostsQuery(w, r, true) +} + +func GetPost(w http.ResponseWriter, r *http.Request) { + pool := poolFrom(r) + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + row := pool.QueryRow(r.Context(), postSelectBase()+` + WHERE p.id = $1 AND `+publicPostWhere, id) + dto, _, err := scanPostRow(r, row) + if err != nil { + if err == pgx.ErrNoRows { + http.NotFound(w, r) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + atts, _ := loadAttachmentsByPostIDs(r.Context(), pool, []uuid.UUID{id}) + dto.Attachments = atts[id] + if dto.Attachments == nil { + dto.Attachments = []AttachmentDTO{} + } + tags, _ := loadPostTagNames(r.Context(), pool, id) + dto.Tags = tags + writeJSON(w, dto) +} + +func PostAttachmentDownload(w http.ResponseWriter, r *http.Request) { + pool := poolFrom(r) + postID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + aid, err := uuid.Parse(chi.URLParam(r, "aid")) + if err != nil { + http.Error(w, "bad attachment id", http.StatusBadRequest) + return + } + cmd, err := pool.Exec(r.Context(), ` + UPDATE posts SET download_count = download_count + 1, updated_at = NOW() + WHERE id = $1 AND status = 'published' AND is_public = TRUE + AND (published_at IS NULL OR published_at <= NOW())`, postID) + if err != nil || cmd.RowsAffected() == 0 { + http.NotFound(w, r) + return + } + writeJSON(w, map[string]any{"ok": true}) + _ = aid +} + +func listPostsQuery(w http.ResponseWriter, r *http.Request, searchMode bool) { + pool := poolFrom(r) + limit := postLimitDef(r, 20, 50) + cursor := strings.TrimSpace(r.URL.Query().Get("cursor")) + catSlug := strings.TrimSpace(r.URL.Query().Get("category")) + typ := strings.TrimSpace(r.URL.Query().Get("type")) + if typ == "" { + typ = "all" + } + langFilter := strings.TrimSpace(r.URL.Query().Get("language")) + + base := postSelectBase() + ` WHERE ` + publicPostWhere + args := []any{} + cond := postTypeFilterSQL(typ, &args) + cond += postLanguageFilterSQL(langFilter, &args) + if catSlug != "" { + args = append(args, catSlug) + cond += fmt.Sprintf(` AND c.slug = $%d`, len(args)) + } + if searchMode { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q != "" { + pat := "%" + q + "%" + args = append(args, pat) + n := len(args) + cond += fmt.Sprintf(` AND ( + p.text_zh ILIKE $%d OR p.text_en ILIKE $%d OR p.text_ja ILIKE $%d OR p.text_ko ILIKE $%d OR + p.text_vi ILIKE $%d OR p.text_id ILIKE $%d OR p.text_ms ILIKE $%d OR + EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.filename ILIKE $%d) OR + EXISTS (SELECT 1 FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id = p.id AND t.name ILIKE $%d))`, + n, n, n, n, n, n, n, n, n) + } + } + order := ` ORDER BY p.published_at DESC NULLS LAST, p.id DESC` + if cursor != "" { + pubT, cid, err := decodePostCursor(cursor) + if err != nil { + http.Error(w, "bad cursor", http.StatusBadRequest) + return + } + args = append(args, pubT, cid) + n := len(args) + cond += fmt.Sprintf(` AND (p.published_at, p.id) < ($%d::timestamptz, $%d::uuid)`, n-1, n) + } + args = append(args, limit+1) + limitArg := len(args) + q := base + cond + order + fmt.Sprintf(` LIMIT $%d`, limitArg) + + rows, err := pool.Query(r.Context(), q, args...) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + items, err := collectPostRows(r, rows) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var nextCursor string + if len(items) > limit { + last := items[limit] + items = items[:limit] + if t, err := time.Parse(time.RFC3339, last.PublishedAt); err == nil { + uid, _ := uuid.Parse(last.ID) + nextCursor = encodePostCursor(t, uid) + } + } + writeJSON(w, PostListResponse{Items: items, NextCursor: nextCursor}) +} + +func collectPostRows(r *http.Request, rows pgx.Rows) ([]PostDTO, error) { + defer rows.Close() + var ids []uuid.UUID + list := make([]PostDTO, 0) + for rows.Next() { + dto, _, err := scanPostRow(r, rows) + if err != nil { + return nil, err + } + uid, _ := uuid.Parse(dto.ID) + ids = append(ids, uid) + dto.Attachments = []AttachmentDTO{} + list = append(list, dto) + } + if err := rows.Err(); err != nil { + return nil, err + } + atts, err := loadAttachmentsByPostIDs(r.Context(), poolFrom(r), ids) + if err != nil { + return nil, err + } + for i := range list { + uid, _ := uuid.Parse(list[i].ID) + if a, ok := atts[uid]; ok { + list[i].Attachments = a + } + } + return list, nil +} diff --git a/internal/handlers/public.go b/internal/handlers/public.go index f2625de..39f50a7 100644 --- a/internal/handlers/public.go +++ b/internal/handlers/public.go @@ -46,36 +46,11 @@ type ResourceDTO struct { Tags []string `json:"tags,omitempty"` } -func pickLangName(r *http.Request, zhTW, zhCN, en string) string { - lang := strings.TrimSpace(r.URL.Query().Get("lang")) - if lang == "" { - lang = r.Header.Get("Accept-Language") - } - lang = strings.ToLower(strings.TrimSpace(strings.Split(lang, ",")[0])) - switch { - case strings.HasPrefix(lang, "zh-cn") || lang == "zh-hans": - if zhCN != "" { - return zhCN - } - case strings.HasPrefix(lang, "en"): - if en != "" { - return en - } - } - if zhTW != "" { - return zhTW - } - if zhCN != "" { - return zhCN - } - return en -} - func ListCategories(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) rows, err := pool.Query(r.Context(), ` - SELECT id, slug, name_zh_tw, name_zh_cn, name_en, description_zh_tw, icon_key, sort_order, updated_at - FROM categories WHERE is_visible = TRUE ORDER BY sort_order ASC, id ASC`) + SELECT c.id, c.slug, `+categoryI18nColsSQL+`, c.icon_key, c.sort_order, c.updated_at + FROM categories c WHERE c.is_visible = TRUE ORDER BY c.sort_order ASC, c.id ASC`) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -84,15 +59,19 @@ func ListCategories(w http.ResponseWriter, r *http.Request) { out := make([]CategoryDTO, 0) for rows.Next() { var c CategoryDTO - var zhTW, zhCN, en *string - var desc *string + var nameZh, nameEn, nameJa, nameKo, nameVi, nameId, nameMs string + var descZh, descEn, descJa, descKo, descVi, descId, descMs string var updated time.Time - if err := rows.Scan(&c.ID, &c.Slug, &zhTW, &zhCN, &en, &desc, &c.IconKey, &c.SortOrder, &updated); err != nil { + if err := rows.Scan(&c.ID, &c.Slug, + &nameZh, &nameEn, &nameJa, &nameKo, &nameVi, &nameId, &nameMs, + &descZh, &descEn, &descJa, &descKo, &descVi, &descId, &descMs, + &c.IconKey, &c.SortOrder, &updated); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - c.Name = pickLangName(r, deref(zhTW), deref(zhCN), deref(en)) - c.Description = deref(desc) + texts := scanCategoryTextI18n(nameZh, nameEn, nameJa, nameKo, nameVi, nameId, nameMs, descZh, descEn, descJa, descKo, descVi, descId, descMs) + c.Name = texts.pickName(r) + c.Description = texts.pickDesc(r) c.UpdatedAt = updated.UTC().Format(time.RFC3339) out = append(out, c) } @@ -138,7 +117,7 @@ func ListResources(w http.ResponseWriter, r *http.Request) { } base := ` - SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en, + SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, ` + categoryNameColsSQL + `, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''), r.is_downloadable, r.is_recommended, r.published_at, r.updated_at FROM resources r JOIN categories c ON c.id = r.category_id @@ -159,10 +138,12 @@ func ListResources(w http.ResponseWriter, r *http.Request) { start := len(args) + 1 args = append(args, pat) cond += fmt.Sprintf(` AND ( - r.title_zh_tw ILIKE $%d OR r.title_zh_cn ILIKE $%d OR r.title_en ILIKE $%d OR - r.description_zh_tw ILIKE $%d OR r.description_zh_cn ILIKE $%d OR r.description_en ILIKE $%d OR + r.title_zh ILIKE $%d OR r.title_en ILIKE $%d OR r.title_ja ILIKE $%d OR r.title_ko ILIKE $%d OR + r.title_vi ILIKE $%d OR r.title_id ILIKE $%d OR r.title_ms ILIKE $%d OR + r.description_zh ILIKE $%d OR r.description_en ILIKE $%d OR r.description_ja ILIKE $%d OR + r.description_ko ILIKE $%d OR r.description_vi ILIKE $%d OR r.description_id ILIKE $%d OR r.description_ms ILIKE $%d OR EXISTS (SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = r.id AND t.name ILIKE $%d))`, - start, start, start, start, start, start, start) + start, start, start, start, start, start, start, start, start, start, start, start, start, start, start) } tag := strings.TrimSpace(r.URL.Query().Get("tag")) if tag != "" { @@ -217,7 +198,7 @@ func listFeatured(w http.ResponseWriter, r *http.Request, extra string, order st limit = 50 } sqlStr := ` - SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en, + SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, ` + categoryNameColsSQL + `, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''), r.is_downloadable, r.is_recommended, r.published_at, r.updated_at FROM resources r JOIN categories c ON c.id = r.category_id @@ -247,7 +228,7 @@ func GetResource(w http.ResponseWriter, r *http.Request) { return } row := pool.QueryRow(r.Context(), ` - SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en, + SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, `+categoryNameColsSQL+`, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''), r.is_downloadable, r.is_recommended, r.published_at, r.updated_at FROM resources r JOIN categories c ON c.id = r.category_id @@ -278,7 +259,7 @@ func RelatedResources(w http.ResponseWriter, r *http.Request) { return } rows, err := pool.Query(r.Context(), ` - SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh-TW'), r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en, + SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, `+categoryNameColsSQL+`, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''), r.is_downloadable, r.is_recommended, r.published_at, r.updated_at FROM resources r JOIN categories c ON c.id = r.category_id @@ -404,15 +385,16 @@ func scanResourceRow(scanner interface { var dto ResourceDTO var pubAt *time.Time var updated time.Time - var zhTW, zhCN, en *string + var catNameZh, catNameEn, catNameJa, catNameKo, catNameVi, catNameId, catNameMs string var id uuid.UUID var texts resourceTextI18n err := scanner.Scan( &id, - &texts.TitleZhTw, &texts.TitleZhCn, &texts.TitleEn, - &texts.DescZhTw, &texts.DescZhCn, &texts.DescEn, - &texts.BodyZhTw, &texts.BodyZhCn, &texts.BodyEn, - &dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, &zhTW, &zhCN, &en, + &texts.TitleZh, &texts.TitleEn, &texts.TitleJa, &texts.TitleKo, &texts.TitleVi, &texts.TitleId, &texts.TitleMs, + &texts.DescZh, &texts.DescEn, &texts.DescJa, &texts.DescKo, &texts.DescVi, &texts.DescId, &texts.DescMs, + &texts.BodyZh, &texts.BodyEn, &texts.BodyJa, &texts.BodyKo, &texts.BodyVi, &texts.BodyId, &texts.BodyMs, + &dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, + &catNameZh, &catNameEn, &catNameJa, &catNameKo, &catNameVi, &catNameId, &catNameMs, &dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BadgeLabel, &dto.IsDownloadable, &dto.IsRecommended, &pubAt, &updated, ) @@ -421,7 +403,8 @@ func scanResourceRow(scanner interface { } dto.ID = id.String() dto.Title, dto.Description, dto.BodyText = texts.pick(r) - dto.CategoryName = pickLangName(r, deref(zhTW), deref(zhCN), deref(en)) + catTexts := categoryTextI18n{NameZh: catNameZh, NameEn: catNameEn, NameJa: catNameJa, NameKo: catNameKo, NameVi: catNameVi, NameId: catNameId, NameMs: catNameMs} + dto.CategoryName = catTexts.pickName(r) if pubAt != nil { s := pubAt.UTC().Format(time.RFC3339) dto.PublishedAt = &s diff --git a/internal/handlers/resource_schema.go b/internal/handlers/resource_schema.go index c62516d..6c43b63 100644 --- a/internal/handlers/resource_schema.go +++ b/internal/handlers/resource_schema.go @@ -9,30 +9,49 @@ import ( // EnsureResourceI18nColumns adds per-locale text columns and backfills from legacy fields. func EnsureResourceI18nColumns(ctx context.Context, pool *pgxpool.Pool) error { _, err := pool.Exec(ctx, ` + ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_en TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ja TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ko TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_vi TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_id TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ms TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_en TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ja TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ko TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_vi TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_id TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ms TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_en TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ja TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ko TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_vi TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_id TEXT; + ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ms TEXT; ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh_tw TEXT; ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh_cn TEXT; - ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_en TEXT; ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_tw TEXT; ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_cn TEXT; - ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_en TEXT; ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_tw TEXT; - ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_cn TEXT; - ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_en TEXT`) + ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh_cn TEXT`) if err != nil { return err } _, err = pool.Exec(ctx, ` - UPDATE resources SET title_zh_tw = title WHERE COALESCE(title_zh_tw, '') = '' AND COALESCE(title, '') <> ''; - UPDATE resources SET description_zh_tw = description WHERE description_zh_tw IS NULL AND description IS NOT NULL; - UPDATE resources SET body_text_zh_tw = body_text WHERE body_text_zh_tw IS NULL AND body_text IS NOT NULL; - UPDATE resources SET title_zh_cn = title WHERE language = 'zh-CN' AND COALESCE(title_zh_cn, '') = ''; - UPDATE resources SET description_zh_cn = description WHERE language = 'zh-CN' AND description_zh_cn IS NULL; - UPDATE resources SET body_text_zh_cn = body_text WHERE language = 'zh-CN' AND body_text_zh_cn IS NULL; - UPDATE resources SET title_en = title WHERE language = 'en' AND COALESCE(title_en, '') = ''; + UPDATE resources SET title_zh = COALESCE(NULLIF(title_zh, ''), NULLIF(title_zh_cn, ''), NULLIF(title_zh_tw, ''), title) + WHERE COALESCE(title_zh, '') = ''; + UPDATE resources SET description_zh = COALESCE(description_zh, description_zh_cn, description_zh_tw, description) + WHERE description_zh IS NULL; + UPDATE resources SET body_text_zh = COALESCE(body_text_zh, body_text_zh_cn, body_text_zh_tw, body_text) + WHERE body_text_zh IS NULL; + UPDATE resources SET title_en = title WHERE language = 'en' AND COALESCE(title_en, '') = '' AND COALESCE(title, '') <> ''; UPDATE resources SET description_en = description WHERE language = 'en' AND description_en IS NULL; UPDATE resources SET body_text_en = body_text WHERE language = 'en' AND body_text_en IS NULL; - UPDATE resources SET title = COALESCE(NULLIF(title_zh_tw, ''), title); - UPDATE resources SET description = description_zh_tw; - UPDATE resources SET body_text = body_text_zh_tw`) + UPDATE resources SET title = COALESCE(NULLIF(title_zh, ''), title); + UPDATE resources SET description = description_zh; + UPDATE resources SET body_text = body_text_zh; + UPDATE resources SET language = 'zh' WHERE language IN ('zh-TW', 'zh-CN', 'zh-tw', 'zh-cn')`) return err } diff --git a/internal/handlers/resource_text.go b/internal/handlers/resource_text.go index 458ba97..1d8a1ce 100644 --- a/internal/handlers/resource_text.go +++ b/internal/handlers/resource_text.go @@ -5,45 +5,91 @@ import ( "strings" ) -// SQL fragment: nine i18n text columns on resources. const resourceI18nColsSQL = ` - COALESCE(r.title_zh_tw,''), COALESCE(r.title_zh_cn,''), COALESCE(r.title_en,''), - COALESCE(r.description_zh_tw,''), COALESCE(r.description_zh_cn,''), COALESCE(r.description_en,''), - COALESCE(r.body_text_zh_tw,''), COALESCE(r.body_text_zh_cn,''), COALESCE(r.body_text_en,'')` + COALESCE(r.title_zh,''), COALESCE(r.title_en,''), COALESCE(r.title_ja,''), + COALESCE(r.title_ko,''), COALESCE(r.title_vi,''), COALESCE(r.title_id,''), COALESCE(r.title_ms,''), + COALESCE(r.description_zh,''), COALESCE(r.description_en,''), COALESCE(r.description_ja,''), + COALESCE(r.description_ko,''), COALESCE(r.description_vi,''), COALESCE(r.description_id,''), COALESCE(r.description_ms,''), + COALESCE(r.body_text_zh,''), COALESCE(r.body_text_en,''), COALESCE(r.body_text_ja,''), + COALESCE(r.body_text_ko,''), COALESCE(r.body_text_vi,''), COALESCE(r.body_text_id,''), COALESCE(r.body_text_ms,'')` type resourceTextI18n struct { - TitleZhTw, TitleZhCn, TitleEn string - DescZhTw, DescZhCn, DescEn string - BodyZhTw, BodyZhCn, BodyEn string + TitleZh, TitleEn, TitleJa, TitleKo, TitleVi, TitleId, TitleMs string + DescZh, DescEn, DescJa, DescKo, DescVi, DescId, DescMs string + BodyZh, BodyEn, BodyJa, BodyKo, BodyVi, BodyId, BodyMs string } -func pickLangField(r *http.Request, zhTW, zhCN, en string) string { +func pickLangField(r *http.Request, zh, en, ja, ko, vi, id, ms string) string { lang := strings.TrimSpace(r.URL.Query().Get("lang")) if lang == "" { lang = r.Header.Get("Accept-Language") } lang = strings.ToLower(strings.TrimSpace(strings.Split(lang, ",")[0])) switch { - case strings.HasPrefix(lang, "zh-cn"), lang == "zh-hans": - if zhCN != "" { - return zhCN + case strings.HasPrefix(lang, "zh"): + if zh != "" { + return zh } case strings.HasPrefix(lang, "en"): if en != "" { return en } + case strings.HasPrefix(lang, "ja"): + if ja != "" { + return ja + } + case strings.HasPrefix(lang, "ko"): + if ko != "" { + return ko + } + case strings.HasPrefix(lang, "vi"): + if vi != "" { + return vi + } + case lang == "id", strings.HasPrefix(lang, "in"): + if id != "" { + return id + } + case strings.HasPrefix(lang, "ms"): + if ms != "" { + return ms + } } - if zhTW != "" { - return zhTW + if zh != "" { + return zh } - if zhCN != "" { - return zhCN + if en != "" { + return en } - return en + if ja != "" { + return ja + } + if ko != "" { + return ko + } + if vi != "" { + return vi + } + if id != "" { + return id + } + return ms } func (t resourceTextI18n) pick(r *http.Request) (title, description, body string) { - return pickLangField(r, t.TitleZhTw, t.TitleZhCn, t.TitleEn), - pickLangField(r, t.DescZhTw, t.DescZhCn, t.DescEn), - pickLangField(r, t.BodyZhTw, t.BodyZhCn, t.BodyEn) + return pickLangField(r, t.TitleZh, t.TitleEn, t.TitleJa, t.TitleKo, t.TitleVi, t.TitleId, t.TitleMs), + pickLangField(r, t.DescZh, t.DescEn, t.DescJa, t.DescKo, t.DescVi, t.DescId, t.DescMs), + pickLangField(r, t.BodyZh, t.BodyEn, t.BodyJa, t.BodyKo, t.BodyVi, t.BodyId, t.BodyMs) +} + +func scanResourceTextI18n( + titleZh, titleEn, titleJa, titleKo, titleVi, titleId, titleMs string, + descZh, descEn, descJa, descKo, descVi, descId, descMs string, + bodyZh, bodyEn, bodyJa, bodyKo, bodyVi, bodyId, bodyMs string, +) resourceTextI18n { + return resourceTextI18n{ + TitleZh: titleZh, TitleEn: titleEn, TitleJa: titleJa, TitleKo: titleKo, TitleVi: titleVi, TitleId: titleId, TitleMs: titleMs, + DescZh: descZh, DescEn: descEn, DescJa: descJa, DescKo: descKo, DescVi: descVi, DescId: descId, DescMs: descMs, + BodyZh: bodyZh, BodyEn: bodyEn, BodyJa: bodyJa, BodyKo: bodyKo, BodyVi: bodyVi, BodyId: bodyId, BodyMs: bodyMs, + } } diff --git a/internal/handlers/upload.go b/internal/handlers/upload.go index e91006a..b8afcc1 100644 --- a/internal/handlers/upload.go +++ b/internal/handlers/upload.go @@ -3,12 +3,18 @@ package handlers import ( "bytes" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "io" "net/http" "os" "path/filepath" "strings" + _ "golang.org/x/image/webp" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" @@ -88,7 +94,7 @@ func UploadFile(d UploadDeps) http.HandlerFunc { return } pub := publicObjectURL(d.S3PublicBase, d.S3Bucket, d.AWSRegion, key) - writeJSON(w, map[string]any{"url": pub, "filename": name, "storage": "s3"}) + writeUploadJSON(w, pub, hdr.Filename, name, ct, int64(len(data)), data, "s3") return } @@ -107,10 +113,33 @@ func UploadFile(d UploadDeps) http.HandlerFunc { http.Error(w, err.Error(), http.StatusInternalServerError) return } - writeJSON(w, map[string]any{"url": "/uploads/" + name, "filename": name, "storage": "local"}) + writeUploadJSON(w, "/uploads/"+name, hdr.Filename, name, ct, int64(len(data)), data, "local") } } +func writeUploadJSON(w http.ResponseWriter, url, originalName, storedName, mime string, size int64, data []byte, storage string) { + kind := classifyAttachmentKind(mime, originalName) + if kind == "" { + kind = classifyAttachmentKind(mime, storedName) + } + out := map[string]any{ + "url": url, + "filename": storedName, + "storage": storage, + "mime": mime, + "sizeBytes": size, + "kind": kind, + } + if strings.HasPrefix(strings.ToLower(mime), "image/") { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err == nil { + out["width"] = cfg.Width + out["height"] = cfg.Height + } + } + writeJSON(w, out) +} + // s3PutObjectCannedACL maps env S3_OBJECT_ACL to SDK enum; unknown values are ignored. func s3PutObjectCannedACL(raw string) (types.ObjectCannedACL, bool) { switch strings.TrimSpace(strings.ToLower(raw)) { diff --git a/migrations/006_resource_seven_locales.sql b/migrations/006_resource_seven_locales.sql new file mode 100644 index 0000000..5d8f66a --- /dev/null +++ b/migrations/006_resource_seven_locales.sql @@ -0,0 +1,32 @@ +-- Seven resource locales: zh (Simplified Chinese), en, ja, ko, vi, id, ms. + +ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_zh TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ja TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ko TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_vi TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_id TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS title_ms TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ja TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ko TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_vi TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_id TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_ms TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_zh TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ja TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ko TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_vi TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_id TEXT; +ALTER TABLE resources ADD COLUMN IF NOT EXISTS body_text_ms TEXT; + +UPDATE resources SET title_zh = COALESCE(NULLIF(title_zh, ''), NULLIF(title_zh_cn, ''), NULLIF(title_zh_tw, ''), title) + WHERE COALESCE(title_zh, '') = ''; +UPDATE resources SET description_zh = COALESCE(description_zh, description_zh_cn, description_zh_tw, description) + WHERE description_zh IS NULL; +UPDATE resources SET body_text_zh = COALESCE(body_text_zh, body_text_zh_cn, body_text_zh_tw, body_text) + WHERE body_text_zh IS NULL; + +UPDATE resources SET title = COALESCE(NULLIF(title_zh, ''), title); +UPDATE resources SET description = description_zh; +UPDATE resources SET body_text = body_text_zh; +UPDATE resources SET language = 'zh' WHERE language IN ('zh-TW', 'zh-CN', 'zh-tw', 'zh-cn'); diff --git a/migrations/007_category_seven_locales.sql b/migrations/007_category_seven_locales.sql new file mode 100644 index 0000000..a78bce7 --- /dev/null +++ b/migrations/007_category_seven_locales.sql @@ -0,0 +1,21 @@ +-- Category names/descriptions: zh (Simplified), en, ja, ko, vi, id, ms. + +ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_zh TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_en TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ja TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ko TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_vi TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_id TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS name_ms TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_zh TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_en TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ja TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ko TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_vi TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_id TEXT; +ALTER TABLE categories ADD COLUMN IF NOT EXISTS description_ms TEXT; + +UPDATE categories SET name_zh = COALESCE(NULLIF(name_zh, ''), NULLIF(name_zh_cn, ''), name_zh_tw) + WHERE COALESCE(name_zh, '') = ''; +UPDATE categories SET description_zh = COALESCE(description_zh, description_zh_tw) + WHERE description_zh IS NULL; diff --git a/migrations/008_posts.sql b/migrations/008_posts.sql new file mode 100644 index 0000000..da1ed26 --- /dev/null +++ b/migrations/008_posts.sql @@ -0,0 +1,52 @@ +-- Telegram-style posts feed (separate from legacy resources) + +CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id INT NOT NULL REFERENCES categories(id), + language TEXT NOT NULL DEFAULT 'zh', + text_zh TEXT, + text_en TEXT, + text_ja TEXT, + text_ko TEXT, + text_vi TEXT, + text_id TEXT, + text_ms TEXT, + is_public BOOLEAN NOT NULL DEFAULT TRUE, + is_recommended BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'draft', + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + view_count INT NOT NULL DEFAULT 0, + download_count INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category_id); +CREATE INDEX IF NOT EXISTS idx_posts_status_public ON posts(status, is_public); +CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published_at DESC NULLS LAST, id DESC); +CREATE INDEX IF NOT EXISTS idx_posts_recommended ON posts(is_recommended) WHERE is_recommended = TRUE; + +CREATE TABLE IF NOT EXISTS post_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + url TEXT NOT NULL, + mime TEXT NOT NULL DEFAULT 'application/octet-stream', + filename TEXT NOT NULL DEFAULT '', + size_bytes BIGINT NOT NULL DEFAULT 0, + width INT, + height INT, + duration_sec INT, + poster_url TEXT, + thumbnail_url TEXT, + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_post_attachments_post ON post_attachments(post_id, sort_order ASC); + +CREATE TABLE IF NOT EXISTS post_tags ( + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (post_id, tag_id) +);