Compare commits

8 Commits

Author SHA1 Message Date
12b4ee536e 1
Some checks failed
Deploy API / deploy (push) Failing after 26s
2026-05-25 23:43:55 +08:00
b2879720de 1
Some checks failed
Deploy API / deploy (push) Failing after 9s
2026-05-25 16:45:33 +08:00
69176e986b 1
All checks were successful
Deploy API / deploy (push) Successful in 33s
2026-05-25 00:09:44 +08:00
5fe13358c1 Revert CI deploy target to backend-1 (100.93.205.19).
All checks were successful
Deploy API / deploy (push) Successful in 8s
Admin host local API is owned by Arkie-Library-Admin; Backend repo stays on ark-library-backend-1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 16:44:42 +08:00
8c22637f21 Point CI deploy at admin host local API instead of backend-1.
Some checks failed
Deploy Admin API / deploy (push) Failing after 7s
Admin API runs on ark-library-backend-admin-1 (:8081) with same-origin nginx; add compose file for that stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 16:38:48 +08:00
728f20b896 1
All checks were successful
Deploy API / deploy (push) Successful in 32s
2026-05-19 07:45:44 +08:00
d9b900d290 1
Some checks failed
Deploy API / deploy (push) Failing after 5s
2026-05-19 07:43:30 +08:00
5226990e64 1
Some checks failed
Deploy API / deploy (push) Failing after 6s
2026-05-19 07:37:25 +08:00
22 changed files with 1447 additions and 68 deletions

108
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,108 @@
# Push to main → scp backend sources to API host, rebuild api container.
#
# ark-library-backend-1 is a *local* SSH alias only. The act runner cannot resolve it.
# Default host is the Tailscale IP from ~/.ssh/config (Host ark-library-backend-1 → 100.93.205.19).
# The Gitea act runner must be on the same Tailscale tailnet, OR set secret DEPLOY_HOST to a reachable IP/DNS.
#
# Secrets (Settings → Actions → Secrets):
# DEPLOY_SSH_KEY — required: ark-library.pem contents (ec2-user, no passphrase)
# DEPLOY_HOST — optional: override default 100.93.205.19
# DEPLOY_USER — optional: default ec2-user
# REMOTE_REPO — optional: default /home/ec2-user/arkieproject
name: Deploy API
on:
push:
branches: [main]
workflow_dispatch:
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
REMOTE_REPO: ${{ secrets.REMOTE_REPO }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve deploy target
run: |
# Tailscale IP for ark-library-backend-1 (see ~/.ssh/config on dev machine)
host="${DEPLOY_HOST:-100.93.205.19}"
repo="${REMOTE_REPO:-/home/ec2-user/arkieproject}"
echo "DEPLOY_HOST=${host}" >> "$GITHUB_ENV"
echo "DEPLOY_USER=${DEPLOY_USER:-ec2-user}" >> "$GITHUB_ENV"
echo "REMOTE_REPO=${repo}" >> "$GITHUB_ENV"
echo "REMOTE_BACKEND=${repo}/backend" >> "$GITHUB_ENV"
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
if [[ -z "${DEPLOY_SSH_KEY}" ]]; then
echo "Missing repository secret DEPLOY_SSH_KEY" >&2
exit 1
fi
install -d -m 700 ~/.ssh
printf '%s\n' "${DEPLOY_SSH_KEY}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
{
echo "Host deploy-target"
echo " HostName ${DEPLOY_HOST}"
echo " User ${DEPLOY_USER}"
echo " IdentityFile ~/.ssh/deploy_key"
echo " StrictHostKeyChecking accept-new"
echo " ConnectTimeout 30"
} >> ~/.ssh/config
chmod 600 ~/.ssh/config
ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Verify SSH reachability
run: |
echo "Testing SSH to ${DEPLOY_USER}@${DEPLOY_HOST} ..."
if ! ssh -o BatchMode=yes -o ConnectTimeout=15 deploy-target "echo ok" 2>&1; then
echo "::error::Cannot SSH to ${DEPLOY_HOST}. The act runner must reach this host (same Tailscale tailnet, or set DEPLOY_HOST secret to a public IP/DNS). Local alias ark-library-backend-1 does not work on CI."
exit 1
fi
- name: Package backend sources
run: |
tar czf /tmp/backend-deploy.tar.gz \
--exclude='./.git' \
--exclude='./uploads' \
--exclude='./.env' \
--exclude='./.env.*' \
--exclude='./.DS_Store' \
--exclude='./.gitea' \
-C . .
- name: Deploy backend sources (scp)
run: |
scp -o ConnectTimeout=30 /tmp/backend-deploy.tar.gz deploy-target:/tmp/backend-deploy.tar.gz
ssh deploy-target bash -s <<REMOTE
set -euo pipefail
REMOTE_BACKEND="${REMOTE_BACKEND}"
mkdir -p "\${REMOTE_BACKEND}"
find "\${REMOTE_BACKEND}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
tar xzf /tmp/backend-deploy.tar.gz -C "\${REMOTE_BACKEND}"
rm -f /tmp/backend-deploy.tar.gz
REMOTE
- name: Rebuild and restart API container
run: |
ssh deploy-target bash -s <<REMOTE
set -euo pipefail
cd "${REMOTE_REPO}"
if [[ ! -f .env ]]; then
echo "Missing ${REMOTE_REPO}/.env on server — bootstrap with deploy/sync-admin.sh first." >&2
exit 1
fi
DC='sudo docker compose -f deploy/docker-compose.admin.yml --env-file .env'
\${DC} build api
\${DC} up -d --no-deps api
\${DC} ps api
REMOTE

View File

@@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS build
FROM golang:1.25-alpine AS build
WORKDIR /src
RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum* ./

View File

@@ -39,6 +39,16 @@ func main() {
log.Print("RUN_WALLET_AUTH_SCHEMA=false: skipping wallet_auth_* DDL at startup")
}
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)
}
@@ -73,6 +83,7 @@ func main() {
AWSRegion: cfg.AWSRegion,
S3Prefix: cfg.S3UploadPrefix,
S3PublicBase: cfg.S3PublicBase,
S3ObjectACL: cfg.S3ObjectACL,
}
r := chi.NewRouter()
@@ -108,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

@@ -24,6 +24,8 @@ type Config struct {
AWSRegion string
S3UploadPrefix string
S3PublicBase string // optional CDN / website endpoint base URL for returned object URLs
// S3ObjectACL: optional canned ACL on PutObject (e.g. public-read). Empty = omit ACL (required for Bucket owner enforced buckets).
S3ObjectACL string
}
func Load() Config {
@@ -74,6 +76,7 @@ func Load() Config {
}
s3Prefix = strings.Trim(s3Prefix, "/")
s3Public := strings.TrimSpace(os.Getenv("S3_PUBLIC_BASE_URL"))
s3ObjectACL := strings.TrimSpace(os.Getenv("S3_OBJECT_ACL"))
return Config{
Addr: addr,
DatabaseURL: db,
@@ -89,6 +92,7 @@ func Load() Config {
AWSRegion: awsRegion,
S3UploadPrefix: s3Prefix,
S3PublicBase: s3Public,
S3ObjectACL: s3ObjectACL,
}
}

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,64 @@
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)
);
ALTER TABLE posts ADD COLUMN IF NOT EXISTS post_type TEXT NOT NULL DEFAULT 'text';
ALTER TABLE posts ALTER COLUMN category_id DROP NOT NULL;
CREATE INDEX IF NOT EXISTS idx_posts_post_type ON posts(post_type)`)
return err
}

View File

@@ -0,0 +1,86 @@
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 (t postTextI18n) toLocalizations() map[string]postLocalePayload {
return map[string]postLocalePayload{
"zh": {Text: t.TextZh},
"en": {Text: t.TextEn},
"ja": {Text: t.TextJa},
"ko": {Text: t.TextKo},
"vi": {Text: t.TextVi},
"id": {Text: t.TextId},
"ms": {Text: t.TextMs},
}
}
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,35 @@
package handlers
import "strings"
// Valid post_type values (Browse filter chips + admin selector).
var validPostTypes = map[string]bool{
"image": true, "video": true, "music": true, "ppt": true, "pdf": true,
"link": true, "text": true, "archive": true,
}
func normalizePostType(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
switch s {
case "图片", "圖片":
return "image"
case "视频", "視頻", "影片":
return "video"
case "音乐", "音樂", "音频", "音頻":
return "music"
case "ppt", "幻灯片", "簡報":
return "ppt"
case "pdf":
return "pdf"
case "链接", "連結", "link":
return "link"
case "文字", "文贴", "文貼", "text":
return "text"
case "压缩包", "壓縮包", "archive", "zip":
return "archive"
}
if validPostTypes[s] {
return s
}
return "text"
}

View File

@@ -0,0 +1,443 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type AttachmentDTO struct {
ID string `json:"id"`
Kind string `json:"kind"`
URL string `json:"url"`
Mime string `json:"mime"`
Filename string `json:"filename"`
SizeBytes int64 `json:"sizeBytes"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
DurationSec *int `json:"durationSec,omitempty"`
PosterURL string `json:"posterUrl,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
}
type PostDTO struct {
ID string `json:"id"`
PostType string `json:"postType"`
CategoryID int `json:"categoryId,omitempty"`
CategorySlug string `json:"categorySlug,omitempty"`
Language string `json:"language"`
Text string `json:"text,omitempty"`
Localizations map[string]postLocalePayload `json:"localizations"`
Attachments []AttachmentDTO `json:"attachments"`
IsRecommended bool `json:"isRecommended"`
PublishedAt string `json:"publishedAt"`
UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type PostListResponse struct {
Items []PostDTO `json:"items"`
NextCursor string `json:"nextCursor,omitempty"`
}
type attachmentInput struct {
ID string `json:"id"`
Kind string `json:"kind"`
URL string `json:"url"`
Mime string `json:"mime"`
Filename string `json:"filename"`
SizeBytes int64 `json:"sizeBytes"`
Width *int `json:"width"`
Height *int `json:"height"`
DurationSec *int `json:"durationSec"`
PosterURL string `json:"posterUrl"`
ThumbnailURL string `json:"thumbnailUrl"`
SortOrder int `json:"sortOrder"`
}
type adminPost struct {
ID string `json:"id"`
PostType string `json:"postType"`
CategoryID int `json:"categoryId,omitempty"`
Language string `json:"language"`
Text string `json:"text"`
Attachments []attachmentInput `json:"attachments"`
IsPublic bool `json:"isPublic"`
IsRecommended bool `json:"isRecommended"`
SortOrder int `json:"sortOrder"`
Status string `json:"status"`
PublishedAt *string `json:"publishedAt,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Tags []string `json:"tags,omitempty"`
Localizations map[string]postLocalePayload `json:"localizations,omitempty"`
ViewCount int `json:"viewCount,omitempty"`
DownloadCount int `json:"downloadCount,omitempty"`
}
const publicPostWhere = `p.status = 'published' AND p.is_public = TRUE AND (p.published_at IS NULL OR p.published_at <= NOW())`
func parsePublishedAtPtr(s *string) (*time.Time, error) {
if s == nil || strings.TrimSpace(*s) == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339, strings.TrimSpace(*s))
if err != nil {
t, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(*s))
}
if err != nil {
return nil, err
}
utc := t.UTC()
return &utc, nil
}
func resolvePostPublishedAt(pubIn *string, status string, existing *time.Time, isCreate bool) (*time.Time, error) {
if pubIn != nil && strings.TrimSpace(*pubIn) != "" {
return parsePublishedAtPtr(pubIn)
}
if status == "published" {
if isCreate || existing == nil {
t := time.Now().UTC()
return &t, nil
}
return existing, nil
}
return existing, nil
}
func formatTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func encodePostCursor(pub time.Time, id uuid.UUID) string {
return pub.UTC().Format(time.RFC3339Nano) + "|" + id.String()
}
func decodePostCursor(cursor string) (time.Time, uuid.UUID, error) {
parts := strings.SplitN(cursor, "|", 2)
if len(parts) != 2 {
return time.Time{}, uuid.Nil, fmt.Errorf("bad cursor")
}
t, err := time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
t, err = time.Parse(time.RFC3339, parts[0])
}
if err != nil {
return time.Time{}, uuid.Nil, err
}
id, err := uuid.Parse(parts[1])
if err != nil {
return time.Time{}, uuid.Nil, err
}
return t.UTC(), id, nil
}
func postTypeFilterSQL(typ string, args *[]any) string {
raw := strings.ToLower(strings.TrimSpace(typ))
if raw == "" || raw == "all" {
return ""
}
typ = normalizePostType(typ)
*args = append(*args, typ)
n := len(*args)
// Primary: admin-selected post_type; legacy rows fall back via OR attachment heuristics.
switch typ {
case "image":
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'image')))`, n)
case "video":
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.kind = 'video' OR pa.mime LIKE 'video/%%'))))`, n)
case "music":
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.mime LIKE 'audio/%%' OR pa.filename ILIKE '%%.mp3' OR pa.filename ILIKE '%%.wav' OR pa.filename ILIKE '%%.m4a'))))`, n)
case "pdf":
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%%pdf%%' OR pa.filename ILIKE '%%.pdf')))`, n)
case "ppt":
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%%presentation%%' OR pa.filename ILIKE '%%.ppt%%' OR pa.filename ILIKE '%%.pptx%%')))`, n)
case "archive":
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.filename ILIKE '%%.zip%%' OR pa.filename ILIKE '%%.rar%%' OR pa.filename ILIKE '%%.7z%%')))`, n)
case "text":
return fmt.Sprintf(` AND p.post_type = $%d`, n)
case "link":
return fmt.Sprintf(` AND (p.post_type = $%d OR (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ms ~* 'https?://'))`, n)
default:
return fmt.Sprintf(` AND p.post_type = $%d`, n)
}
}
func normalizePostLangFilter(lang string) string {
lang = strings.TrimSpace(lang)
if lang == "" {
return ""
}
return translateNormalizePostLang(lang)
}
func translateNormalizePostLang(lang string) string {
lang = strings.ToLower(lang)
switch {
case strings.HasPrefix(lang, "zh"):
return "zh"
case strings.HasPrefix(lang, "en"):
return "en"
case strings.HasPrefix(lang, "ja"):
return "ja"
case strings.HasPrefix(lang, "ko"):
return "ko"
case strings.HasPrefix(lang, "vi"):
return "vi"
case lang == "id", strings.HasPrefix(lang, "in"):
return "id"
case strings.HasPrefix(lang, "ms"):
return "ms"
default:
return lang
}
}
func validateAdminPost(ap *adminPost) error {
ap.PostType = normalizePostType(ap.PostType)
if ap.PostType == "" || !validPostTypes[ap.PostType] {
return fmt.Errorf("postType required (image, video, music, ppt, pdf, link, text, archive)")
}
if len(ap.Attachments) > maxPostAttachments {
return fmt.Errorf("too many attachments (max %d)", maxPostAttachments)
}
hasText := strings.TrimSpace(ap.Text) != ""
hasAtt := len(ap.Attachments) > 0
if ap.Status == "published" && !hasText && !hasAtt {
return fmt.Errorf("published post requires text and/or at least one attachment")
}
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, postType string
var pub, updated, created *time.Time
err := row.Scan(
&id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs,
&lang, &postType, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created,
)
if err != nil {
return dto, texts, err
}
dto.ID = id.String()
dto.PostType = normalizePostType(postType)
if catID != nil {
dto.CategoryID = *catID
}
if slug != nil {
dto.CategorySlug = *slug
}
dto.Language = lang
dto.Text = texts.pick(r)
dto.Localizations = texts.toLocalizations()
if pub != nil {
dto.PublishedAt = pub.UTC().Format(time.RFC3339)
} else if created != nil {
dto.PublishedAt = created.UTC().Format(time.RFC3339)
}
if updated != nil {
dto.UpdatedAt = updated.UTC().Format(time.RFC3339)
}
if created != nil {
dto.CreatedAt = created.UTC().Format(time.RFC3339)
}
return dto, texts, nil
}
func postSelectBase() string {
return `
SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), COALESCE(p.post_type,'text'),
p.category_id, c.slug,
p.is_recommended, p.published_at, p.updated_at, p.created_at
FROM posts p
LEFT JOIN categories c ON c.id = p.category_id`
}
func postsFromClause() string {
return `FROM posts p LEFT JOIN categories c ON c.id = p.category_id WHERE 1=1`
}
func postLimitDef(r *http.Request, def, max int) int {
limit := atoiDef(r.URL.Query().Get("limit"), def)
if limit > max {
limit = max
}
return limit
}
func postLanguageFilterSQL(lang string, args *[]any) string {
norm := normalizePostLangFilter(lang)
if norm == "" {
return ""
}
*args = append(*args, norm)
return fmt.Sprintf(` AND p.language = $%d`, len(*args))
}

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)
}
@@ -121,7 +100,6 @@ func ListResources(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
typ := strings.TrimSpace(r.URL.Query().Get("type"))
lang := strings.TrimSpace(r.URL.Query().Get("language"))
catSlug := strings.TrimSpace(r.URL.Query().Get("category"))
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
if sort == "" {
@@ -139,8 +117,8 @@ func ListResources(w http.ResponseWriter, r *http.Request) {
}
base := `
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
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
WHERE r.status = 'published' AND r.is_public = TRUE`
@@ -155,17 +133,17 @@ func ListResources(w http.ResponseWriter, r *http.Request) {
args = append(args, typ)
cond += " AND r.type = $" + strconv.Itoa(len(args))
}
if lang != "" {
args = append(args, lang)
cond += " AND r.language = $" + strconv.Itoa(len(args))
}
if q != "" {
pat := "%" + q + "%"
start := len(args) + 1
args = append(args, pat, pat, pat)
cond += fmt.Sprintf(` AND (r.title ILIKE $%d OR r.description 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+1, start+2)
args = append(args, pat)
cond += fmt.Sprintf(` AND (
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)
}
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
if tag != "" {
@@ -220,8 +198,8 @@ func listFeatured(w http.ResponseWriter, r *http.Request, extra string, order st
limit = 50
}
sqlStr := `
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
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
WHERE r.status = 'published' AND r.is_public = TRUE AND (` + extra + `)
@@ -250,8 +228,8 @@ func GetResource(w http.ResponseWriter, r *http.Request) {
return
}
row := pool.QueryRow(r.Context(), `
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
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
WHERE r.id = $1 AND r.status = 'published' AND r.is_public = TRUE`, id)
@@ -281,8 +259,8 @@ func RelatedResources(w http.ResponseWriter, r *http.Request) {
return
}
rows, err := pool.Query(r.Context(), `
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id, c.slug, c.name_zh_tw, c.name_zh_cn, c.name_en,
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''), COALESCE(r.badge_label,''),
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
WHERE r.status = 'published' AND r.is_public = TRUE AND r.category_id = $1 AND r.id <> $2
@@ -407,18 +385,26 @@ 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, &dto.Title, &dto.Description, &dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, &zhTW, &zhCN, &en,
&dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BodyText, &dto.BadgeLabel,
&id,
&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,
)
if err != nil {
return dto, err
}
dto.ID = id.String()
dto.CategoryName = pickLangName(r, deref(zhTW), deref(zhCN), deref(en))
dto.Title, dto.Description, dto.BodyText = texts.pick(r)
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

@@ -0,0 +1,57 @@
package handlers
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// 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 description_zh_tw TEXT;
ALTER TABLE resources ADD COLUMN IF NOT EXISTS description_zh_cn 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`)
if err != nil {
return err
}
_, err = pool.Exec(ctx, `
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, ''), 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

@@ -0,0 +1,95 @@
package handlers
import (
"net/http"
"strings"
)
const resourceI18nColsSQL = `
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 {
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, 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"):
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 zh != "" {
return zh
}
if 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.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,14 +3,21 @@ 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"
"github.com/google/uuid"
)
@@ -25,6 +32,7 @@ type UploadDeps struct {
AWSRegion string
S3Prefix string // e.g. "uploads" (no leading/trailing slashes)
S3PublicBase string // optional, e.g. https://cdn.example.com — else virtual-hosted S3 URL
S3ObjectACL string // optional canned ACL, e.g. public-read (bucket must allow ACLs)
}
func UploadFile(d UploadDeps) http.HandlerFunc {
@@ -71,18 +79,22 @@ func UploadFile(d UploadDeps) http.HandlerFunc {
}
key := pfx + "/" + name
ctx := r.Context()
_, err := d.S3.PutObject(ctx, &s3.PutObjectInput{
put := &s3.PutObjectInput{
Bucket: aws.String(d.S3Bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
ContentType: aws.String(ct),
})
}
if acl, ok := s3PutObjectCannedACL(d.S3ObjectACL); ok {
put.ACL = acl
}
_, err := d.S3.PutObject(ctx, put)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
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
}
@@ -101,7 +113,52 @@ 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)) {
case "private":
return types.ObjectCannedACLPrivate, true
case "public-read":
return types.ObjectCannedACLPublicRead, true
case "public-read-write":
return types.ObjectCannedACLPublicReadWrite, true
case "authenticated-read":
return types.ObjectCannedACLAuthenticatedRead, true
case "bucket-owner-full-control":
return types.ObjectCannedACLBucketOwnerFullControl, true
case "bucket-owner-read":
return types.ObjectCannedACLBucketOwnerRead, true
case "aws-exec-read":
return types.ObjectCannedACLAwsExecRead, true
default:
return "", false
}
}

View File

@@ -0,0 +1,27 @@
-- One resource row holds all languages (title / description / body per locale).
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;
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 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;

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

View File

@@ -0,0 +1,6 @@
-- Posts use post_type (image/video/...) instead of category for feed classification.
ALTER TABLE posts ADD COLUMN IF NOT EXISTS post_type TEXT NOT NULL DEFAULT 'text';
ALTER TABLE posts ALTER COLUMN category_id DROP NOT NULL;
CREATE INDEX IF NOT EXISTS idx_posts_post_type ON posts(post_type);