1
Some checks failed
Deploy API / deploy (push) Failing after 9s

This commit is contained in:
2026-05-25 16:45:33 +08:00
parent 69176e986b
commit b2879720de
16 changed files with 1126 additions and 90 deletions

View File

@@ -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)

13
go.mod
View File

@@ -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
)

10
go.sum
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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)) {

View File

@@ -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');

View File

@@ -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;

52
migrations/008_posts.sql Normal file
View File

@@ -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)
);