Compare commits
12 Commits
terry-stag
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e18da8c6d3 | |||
| b9834d9300 | |||
| 09089e1335 | |||
| f8792f8db8 | |||
| 12b4ee536e | |||
| b2879720de | |||
| 69176e986b | |||
| 5fe13358c1 | |||
| 8c22637f21 | |||
| 728f20b896 | |||
| d9b900d290 | |||
| 5226990e64 |
34
.env.example
34
.env.example
@@ -1,34 +0,0 @@
|
||||
# Arkie Library Backend environment example
|
||||
# Copy to .env for your own reference, but note: the Go app does not auto-load .env.
|
||||
# Export these values in your shell, Docker, process manager, or deployment system.
|
||||
|
||||
# HTTP
|
||||
HTTP_ADDR=:8080
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL=postgres://ark:ark@localhost:5432/arkdb?sslmode=disable
|
||||
|
||||
# Auth
|
||||
# Change this in production. Use a long random string.
|
||||
JWT_SECRET=dev-insecure-change-me
|
||||
|
||||
# Wallet auth schema DDL at startup.
|
||||
# true is convenient for dev; false is safer for public/read-only DB users after migrations are applied.
|
||||
RUN_WALLET_AUTH_SCHEMA=true
|
||||
|
||||
# Local uploads
|
||||
UPLOAD_DIR=./uploads
|
||||
UPLOAD_MULTIPART_MEM_MB=64
|
||||
|
||||
# Optional first admin seed. Only creates an admin when the admins table is empty.
|
||||
SEED_ADMIN=false
|
||||
ADMIN_EMAIL=admin@ark.local
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# Optional S3 uploads. Leave S3_BUCKET empty to keep uploads local.
|
||||
S3_BUCKET=
|
||||
AWS_REGION=ap-southeast-1
|
||||
S3_UPLOAD_PREFIX=uploads
|
||||
# Optional CDN/website base URL, e.g. https://cdn.example.com
|
||||
S3_PUBLIC_BASE_URL=
|
||||
108
.gitea/workflows/deploy.yml
Normal file
108
.gitea/workflows/deploy.yml
Normal 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
|
||||
83
AGENTS.md
83
AGENTS.md
@@ -1,83 +0,0 @@
|
||||
# AI Agent Instructions
|
||||
|
||||
This file is first-stop context for AI coding agents working in `Arkie-Library-Backend`.
|
||||
|
||||
## Repository identity
|
||||
|
||||
- Project: Arkie Library Backend / ARK database API.
|
||||
- Go module: `github.com/arkie/ark-database`.
|
||||
- Stack: Go, chi router, PostgreSQL/pgx, JWT, bcrypt, optional AWS S3 uploads, Ethereum wallet signature verification.
|
||||
- Frontend repo expects backend API at `/api` and uploaded assets under `/uploads`.
|
||||
|
||||
## Branch and safety rules
|
||||
|
||||
- Current branch observed during initialization: `terry-staging`.
|
||||
- Do not commit or push unless Terry explicitly asks.
|
||||
- Before branch-changing, pulling, rebasing, or destructive operations, run:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
- Do not commit secrets. `.env.example` is safe; real `.env` files should stay local/deployment-only.
|
||||
|
||||
## Required checks
|
||||
|
||||
Before proposing deploy/push, run:
|
||||
|
||||
```bash
|
||||
gofmt -w .
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Optional extra check:
|
||||
|
||||
```bash
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Architecture quick map
|
||||
|
||||
- `cmd/server/main.go`: starts everything. Loads config, connects DB, ensures upload dir, optionally seeds admin, sets up S3, registers routes.
|
||||
- `internal/config/config.go`: all environment variables and defaults.
|
||||
- `internal/db/db.go`: creates and pings a `pgxpool.Pool`.
|
||||
- `internal/auth/`: JWT helpers. Admin tokens use issuer `ark-admin`; wallet user tokens use issuer `ark-user`.
|
||||
- `internal/handlers/public.go`: public category/resource/search/metric endpoints.
|
||||
- `internal/handlers/admin.go`: admin login, dashboard, resource CRUD, tags.
|
||||
- `internal/handlers/wallet_auth.go`: wallet nonce, signature verification, wallet profile.
|
||||
- `internal/handlers/upload.go`: admin multipart upload to local disk or S3.
|
||||
- `migrations/`: SQL schema files; there is no Go migration runner yet.
|
||||
- `compose.yaml`: local OrbStack/Docker Compose setup with PostgreSQL + API.
|
||||
|
||||
## Important implementation patterns
|
||||
|
||||
- HTTP handlers are plain `net/http` functions using chi route params.
|
||||
- DB access comes from request context via `handlers.WithPool` and `poolFrom(r)`.
|
||||
- JSON responses use `writeJSON` in `internal/handlers/util.go`.
|
||||
- JSON request bodies use `jsonDecode`, limited to 1 MiB.
|
||||
- Resource IDs are UUIDs (`github.com/google/uuid`).
|
||||
- Tags are stored in `tags` + `resource_tags`; new tag slugs are deterministic hashes from `tagSlug`.
|
||||
- Public resource listing only returns `resources.status = 'published'` and `is_public = TRUE`.
|
||||
- Admin routes require `Authorization: Bearer <admin-jwt>` from `/api/admin/login`.
|
||||
- Wallet routes produce normal user JWTs, not admin JWTs.
|
||||
|
||||
## Database/migration notes
|
||||
|
||||
- `migrations/001_init.sql` is designed for a fresh database and is not fully idempotent.
|
||||
- `migrations/002_wallet_auth.sql` is idempotent.
|
||||
- `RUN_WALLET_AUTH_SCHEMA` defaults to true and lets startup create the wallet nonce table. In production with read-only/public DB users, apply migration separately and set it to false.
|
||||
|
||||
## Deployment/runtime notes
|
||||
|
||||
- `compose.yaml` runs local development in OrbStack/Docker: PostgreSQL on host port `5433`, API on host port `8080`.
|
||||
- Dockerfile builds a static Go binary in a `golang:1.24-alpine` builder and runs it on Alpine.
|
||||
- `UPLOAD_DIR` defaults to `./uploads`; Docker sets it to `/app/uploads`.
|
||||
- When `S3_BUCKET` is set and AWS config loads, uploads go to S3. Otherwise uploads stay local.
|
||||
- `JWT_SECRET` default is insecure and must be changed outside development.
|
||||
|
||||
## Agent behavior preferences
|
||||
|
||||
- Answer Terry concisely in Chinese unless code/docs require English.
|
||||
- Prefer small direct changes over broad rewrites.
|
||||
- Keep this README/AGENTS/docs updated when discovering non-obvious backend behavior.
|
||||
- Search project memory before making decisions about workflow, deployment, or conventions.
|
||||
@@ -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* ./
|
||||
|
||||
150
README.md
150
README.md
@@ -1,150 +0,0 @@
|
||||
# Arkie Library Backend
|
||||
|
||||
Go backend for the ARK Library / ARK Database website.
|
||||
|
||||
If Go is new for you: start with **[docs/GO_FOR_BEGINNERS.md](docs/GO_FOR_BEGINNERS.md)**, then read **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**.
|
||||
|
||||
For local Docker/OrbStack development, see **[docs/LOCAL_ORBSTACK.md](docs/LOCAL_ORBSTACK.md)**.
|
||||
|
||||
## What this service does
|
||||
|
||||
- Serves public library data under `/api`.
|
||||
- Serves uploaded files under `/uploads`.
|
||||
- Provides admin login and admin CRUD APIs for resources.
|
||||
- Supports wallet sign-in with Ethereum personal-sign messages.
|
||||
- Stores data in PostgreSQL.
|
||||
- Stores uploads locally by default, or in S3 when configured.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Go module: `github.com/arkie/ark-database`
|
||||
- HTTP router: `github.com/go-chi/chi/v5`
|
||||
- Database: PostgreSQL via `github.com/jackc/pgx/v5/pgxpool`
|
||||
- Auth: JWT via `github.com/golang-jwt/jwt/v5`
|
||||
- Password hashing: bcrypt
|
||||
- Wallet signature verification: `github.com/ethereum/go-ethereum`
|
||||
- Optional uploads: AWS S3 SDK v2
|
||||
|
||||
## Folder map
|
||||
|
||||
```text
|
||||
cmd/server/main.go # application entry point: config, DB, router, endpoints
|
||||
internal/config/config.go # environment variables and default values
|
||||
internal/db/db.go # PostgreSQL connection pool
|
||||
internal/auth/ # JWT signing/parsing for admin and wallet users
|
||||
internal/handlers/ # HTTP handlers: public, admin, upload, wallet auth
|
||||
internal/seed/seed.go # optional initial admin user
|
||||
migrations/ # SQL schema files, apply manually
|
||||
static/ # bundled static assets copied into uploads
|
||||
```
|
||||
|
||||
## Quick local run
|
||||
|
||||
### Option A: OrbStack / Docker Compose
|
||||
|
||||
```bash
|
||||
cd Arkie-Library-Backend
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then check:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
See **[docs/LOCAL_ORBSTACK.md](docs/LOCAL_ORBSTACK.md)** for details.
|
||||
|
||||
### Option B: Manual PostgreSQL + Go
|
||||
|
||||
#### 1) Start PostgreSQL
|
||||
|
||||
Example with Docker:
|
||||
|
||||
```bash
|
||||
docker run --name ark-postgres \
|
||||
-e POSTGRES_USER=ark \
|
||||
-e POSTGRES_PASSWORD=ark \
|
||||
-e POSTGRES_DB=arkdb \
|
||||
-p 5432:5432 \
|
||||
-d postgres:16
|
||||
```
|
||||
|
||||
#### 2) Apply schema once
|
||||
|
||||
`migrations/001_init.sql` is for a fresh empty database. Do not run it twice on the same DB unless you reset the DB first.
|
||||
|
||||
```bash
|
||||
cd Arkie-Library-Backend
|
||||
export DATABASE_URL='postgres://ark:ark@localhost:5432/arkdb?sslmode=disable'
|
||||
psql "$DATABASE_URL" -f migrations/001_init.sql
|
||||
```
|
||||
|
||||
Wallet nonce schema is normally auto-created at app startup because `RUN_WALLET_AUTH_SCHEMA` defaults to true. If you want to apply it manually and disable startup DDL:
|
||||
|
||||
```bash
|
||||
psql "$DATABASE_URL" -f migrations/002_wallet_auth.sql
|
||||
export RUN_WALLET_AUTH_SCHEMA=false
|
||||
```
|
||||
|
||||
#### 3) Configure env
|
||||
|
||||
Copy the example and edit values as needed:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
This project does not auto-load `.env`; either export variables in your shell or run with a dotenv helper.
|
||||
|
||||
#### 4) Run the server
|
||||
|
||||
```bash
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
Check health:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
# Format all Go files
|
||||
gofmt -w .
|
||||
|
||||
# Compile and run all package tests
|
||||
go test ./...
|
||||
|
||||
# Run the backend
|
||||
go run ./cmd/server
|
||||
|
||||
# Build a local binary
|
||||
go build -o ./bin/server ./cmd/server
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
See `.env.example` for all variables. Important ones:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `HTTP_ADDR` | `:8080` | Listen address |
|
||||
| `DATABASE_URL` | `postgres://ark:ark@localhost:5432/arkdb?sslmode=disable` | PostgreSQL connection |
|
||||
| `JWT_SECRET` | `dev-insecure-change-me` | JWT signing key; change in production |
|
||||
| `UPLOAD_DIR` | `./uploads` | Local upload directory |
|
||||
| `CORS_ORIGINS` | empty | Comma-separated allowed browser origins |
|
||||
| `SEED_ADMIN` | false | Set `true`/`1` to create first admin if none exists |
|
||||
| `ADMIN_EMAIL` | `admin@ark.local` | Seed admin email |
|
||||
| `ADMIN_PASSWORD` | `admin123` | Seed admin password |
|
||||
| `S3_BUCKET` | empty | Enables S3 uploads when set |
|
||||
|
||||
## API docs
|
||||
|
||||
See **[docs/API.md](docs/API.md)**.
|
||||
|
||||
## Agent notes
|
||||
|
||||
AI/coding agent instructions are in **[AGENTS.md](AGENTS.md)**.
|
||||
@@ -39,6 +39,19 @@ 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 := handlers.EnsureTagI18nSchema(ctx, pool); err != nil {
|
||||
log.Fatalf("tags i18n schema: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -73,6 +86,7 @@ func main() {
|
||||
AWSRegion: cfg.AWSRegion,
|
||||
S3Prefix: cfg.S3UploadPrefix,
|
||||
S3PublicBase: cfg.S3PublicBase,
|
||||
S3ObjectACL: cfg.S3ObjectACL,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -108,6 +122,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)
|
||||
|
||||
45
compose.yaml
45
compose.yaml
@@ -1,45 +0,0 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: arkie-library-db
|
||||
environment:
|
||||
POSTGRES_USER: ark
|
||||
POSTGRES_PASSWORD: ark
|
||||
POSTGRES_DB: arkdb
|
||||
ports:
|
||||
# Host port 5433 avoids conflicts with any existing local PostgreSQL on 5432.
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- arkie_postgres_data:/var/lib/postgresql/data
|
||||
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
|
||||
- ./migrations/002_wallet_auth.sql:/docker-entrypoint-initdb.d/002_wallet_auth.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ark -d arkdb"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: arkie-library-api
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
HTTP_ADDR: ":8080"
|
||||
DATABASE_URL: "postgres://ark:ark@db:5432/arkdb?sslmode=disable"
|
||||
JWT_SECRET: "local-dev-change-me"
|
||||
CORS_ORIGINS: "${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}"
|
||||
UPLOAD_DIR: "/app/uploads"
|
||||
SEED_ADMIN: "${SEED_ADMIN:-true}"
|
||||
ADMIN_EMAIL: "${ADMIN_EMAIL:-admin@ark.local}"
|
||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-admin123}"
|
||||
RUN_WALLET_AUTH_SCHEMA: "true"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- arkie_uploads:/app/uploads
|
||||
|
||||
volumes:
|
||||
arkie_postgres_data:
|
||||
arkie_uploads:
|
||||
360
docs/API.md
360
docs/API.md
@@ -1,360 +0,0 @@
|
||||
# API Reference
|
||||
|
||||
Base URL in local development: `http://localhost:8080`.
|
||||
|
||||
Frontend usually calls paths like `/api/resources`; uploaded files are available under `/uploads/...`.
|
||||
|
||||
## Response style
|
||||
|
||||
Most responses are JSON. Errors use plain text via `http.Error`, for example `unauthorized` or `bad json`.
|
||||
|
||||
## Health
|
||||
|
||||
### `GET /healthz`
|
||||
|
||||
Returns plain text:
|
||||
|
||||
```text
|
||||
ok
|
||||
```
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Same as `/healthz`.
|
||||
|
||||
## Public categories/resources
|
||||
|
||||
### `GET /api/categories`
|
||||
|
||||
Query/header language behavior:
|
||||
|
||||
- `?lang=zh-CN` returns Simplified Chinese name when available.
|
||||
- `?lang=en` returns English name when available.
|
||||
- otherwise Traditional Chinese is preferred.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"slug": "project-ppt",
|
||||
"name": "項目資料(PPT)",
|
||||
"description": "ARK 項目介紹、簡報與對外展示資料",
|
||||
"iconKey": "folder",
|
||||
"sortOrder": 1,
|
||||
"updatedAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/resources`
|
||||
|
||||
Returns paginated public resources.
|
||||
|
||||
Query params:
|
||||
|
||||
| Param | Meaning |
|
||||
| --- | --- |
|
||||
| `page` | page number, default 1 |
|
||||
| `limit` | page size, default 20, max 100 |
|
||||
| `q` | search title/description/tag name |
|
||||
| `type` | resource type; `all` means no filter |
|
||||
| `language` | exact resource language filter |
|
||||
| `category` | category slug |
|
||||
| `tag` | tag slug or tag name, case-insensitive |
|
||||
| `sort` | `latest` default, `published`, `recommended`, `popular` |
|
||||
| `lang` | category display language, e.g. `en`, `zh-CN` |
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "ARK 項目介紹簡報(示例)",
|
||||
"description": "適合線下宣講與新人培訓。",
|
||||
"type": "ppt",
|
||||
"language": "zh-TW",
|
||||
"categoryId": 1,
|
||||
"categorySlug": "project-ppt",
|
||||
"categoryName": "項目資料(PPT)",
|
||||
"coverImage": "/uploads/placeholder-cover.svg",
|
||||
"fileUrl": "/uploads/placeholder-cover.svg",
|
||||
"isDownloadable": true,
|
||||
"isRecommended": true,
|
||||
"publishedAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-01T00:00:00Z",
|
||||
"tags": ["官方推薦"]
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/resources/recommended?limit=12`
|
||||
|
||||
Returns recommended resources:
|
||||
|
||||
```json
|
||||
{ "items": [] }
|
||||
```
|
||||
|
||||
Max limit is 50.
|
||||
|
||||
### `GET /api/resources/latest?limit=12`
|
||||
|
||||
Returns latest resources:
|
||||
|
||||
```json
|
||||
{ "items": [] }
|
||||
```
|
||||
|
||||
### `GET /api/resources/popular?limit=12`
|
||||
|
||||
Returns resources ordered by download + favorite + share count:
|
||||
|
||||
```json
|
||||
{ "items": [] }
|
||||
```
|
||||
|
||||
### `GET /api/resources/{id}`
|
||||
|
||||
Returns one published public resource. `{id}` must be a UUID.
|
||||
|
||||
### `GET /api/resources/{id}/related`
|
||||
|
||||
Returns up to 8 resources from the same category:
|
||||
|
||||
```json
|
||||
{ "items": [] }
|
||||
```
|
||||
|
||||
## Public activity endpoints
|
||||
|
||||
### `POST /api/search-log`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "query": "ark" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
### `POST /api/resources/{id}/view`
|
||||
|
||||
Increments view count.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
### `POST /api/resources/{id}/download`
|
||||
|
||||
Increments download count.
|
||||
|
||||
### `POST /api/resources/{id}/share`
|
||||
|
||||
Increments share count.
|
||||
|
||||
### `POST /api/resources/{id}/favorite`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "add": true }
|
||||
```
|
||||
|
||||
- `add: true` increments favorite count.
|
||||
- `add: false` decrements favorite count but not below zero.
|
||||
|
||||
## Wallet auth
|
||||
|
||||
Wallet auth is for normal users, not admin users.
|
||||
|
||||
### `POST /api/auth/wallet/nonce`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "address": "0x0000000000000000000000000000000000000000" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"nonce": "hex-code",
|
||||
"message": "ARK Database — wallet sign-in\n\nWallet: 0x...\nOne-time code: ..."
|
||||
}
|
||||
```
|
||||
|
||||
The frontend asks the wallet to sign `message`.
|
||||
|
||||
### `POST /api/auth/wallet/verify`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"address": "0x0000000000000000000000000000000000000000",
|
||||
"message": "same message from nonce endpoint",
|
||||
"signature": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "jwt",
|
||||
"wallet": "0xChecksumAddress"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/auth/wallet/me`
|
||||
|
||||
Requires header:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <wallet-jwt>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "wallet": "0xChecksumAddress", "role": "user" }
|
||||
```
|
||||
|
||||
## Admin auth
|
||||
|
||||
### `POST /api/admin/login`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "email": "admin@ark.local", "password": "admin123" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "token": "admin-jwt" }
|
||||
```
|
||||
|
||||
Use this token for admin endpoints:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <admin-jwt>
|
||||
```
|
||||
|
||||
## Admin endpoints
|
||||
|
||||
All endpoints below require admin JWT.
|
||||
|
||||
### `GET /api/admin/dashboard`
|
||||
|
||||
Response includes counts and hot resources:
|
||||
|
||||
```json
|
||||
{
|
||||
"totalResources": 10,
|
||||
"published": 8,
|
||||
"todayNew": 1,
|
||||
"totalViews": 100,
|
||||
"totalDownloads": 20,
|
||||
"totalFavorites": 5,
|
||||
"totalShares": 3,
|
||||
"hotResources": []
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/admin/search-logs?limit=200`
|
||||
|
||||
Max limit is 500.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "items": [{ "id": 1, "query": "ark", "createdAt": "2026-01-01T00:00:00Z" }] }
|
||||
```
|
||||
|
||||
### `GET /api/admin/resources?page=1&limit=20`
|
||||
|
||||
Lists all resources, including drafts/private resources.
|
||||
|
||||
### `GET /api/admin/resources/{id}`
|
||||
|
||||
Gets one resource by UUID.
|
||||
|
||||
### `POST /api/admin/resources`
|
||||
|
||||
Creates a resource.
|
||||
|
||||
Example request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Example",
|
||||
"description": "Short text",
|
||||
"type": "ppt",
|
||||
"language": "zh-TW",
|
||||
"categoryId": 1,
|
||||
"coverImage": "/uploads/example.svg",
|
||||
"fileUrl": "/uploads/example.pdf",
|
||||
"previewUrl": "",
|
||||
"externalUrl": "",
|
||||
"bodyText": "",
|
||||
"badgeLabel": "新人必看",
|
||||
"isPublic": true,
|
||||
"isDownloadable": true,
|
||||
"isRecommended": false,
|
||||
"sortOrder": 0,
|
||||
"status": "draft",
|
||||
"tags": ["教程"]
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/admin/resources/{id}`
|
||||
|
||||
Updates a resource. Body shape is the same as create.
|
||||
|
||||
### `DELETE /api/admin/resources/{id}`
|
||||
|
||||
Deletes a resource.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
### `POST /api/admin/upload`
|
||||
|
||||
Multipart upload with field name `file`.
|
||||
|
||||
Response for local storage:
|
||||
|
||||
```json
|
||||
{ "url": "/uploads/generated-name.ext", "filename": "generated-name.ext", "storage": "local" }
|
||||
```
|
||||
|
||||
Response for S3:
|
||||
|
||||
```json
|
||||
{ "url": "https://...", "filename": "generated-name.ext", "storage": "s3" }
|
||||
```
|
||||
|
||||
### `GET /api/admin/categories`
|
||||
|
||||
Same handler as public categories; returns visible categories.
|
||||
@@ -1,180 +0,0 @@
|
||||
# Backend Architecture
|
||||
|
||||
This document explains the backend in beginner-friendly language.
|
||||
|
||||
## Big picture
|
||||
|
||||
```text
|
||||
Browser / Frontend
|
||||
|
|
||||
| HTTP JSON requests
|
||||
v
|
||||
cmd/server/main.go
|
||||
|
|
||||
+-- config.Load() reads environment variables
|
||||
+-- db.Connect() creates PostgreSQL pool
|
||||
+-- chi router maps URLs to handler functions
|
||||
+-- handlers read/write DB and return JSON
|
||||
|
|
||||
v
|
||||
PostgreSQL + local uploads or S3
|
||||
```
|
||||
|
||||
The backend is one Go HTTP server. There is no framework magic: `main.go` wires everything manually.
|
||||
|
||||
## Request flow example
|
||||
|
||||
Example: frontend calls `GET /api/resources?limit=20`.
|
||||
|
||||
1. `main.go` has this route:
|
||||
```go
|
||||
r.Get("/resources", handlers.ListResources)
|
||||
```
|
||||
2. The `/api` router has middleware:
|
||||
```go
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return handlers.WithPool(next, pool)
|
||||
})
|
||||
```
|
||||
This places the PostgreSQL pool into the request context.
|
||||
3. `handlers.ListResources` calls `poolFrom(r)` to get the DB pool.
|
||||
4. It reads query string filters (`q`, `type`, `language`, `category`, `sort`, `tag`).
|
||||
5. It builds SQL, queries PostgreSQL, scans rows into `ResourceDTO`.
|
||||
6. It returns JSON using `writeJSON`.
|
||||
|
||||
## Why `internal/`?
|
||||
|
||||
In Go, a folder named `internal` can only be imported by code inside the same module tree. This prevents other modules from depending on private implementation packages.
|
||||
|
||||
This project uses:
|
||||
|
||||
```text
|
||||
internal/config private config package
|
||||
internal/db private DB package
|
||||
internal/auth private JWT package
|
||||
internal/handlers private HTTP handler package
|
||||
internal/seed private seed package
|
||||
```
|
||||
|
||||
## Main packages
|
||||
|
||||
### `cmd/server`
|
||||
|
||||
`cmd/server/main.go` is the executable entry point.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Load environment config.
|
||||
- Connect to PostgreSQL.
|
||||
- Ensure wallet nonce table when enabled.
|
||||
- Create upload directory and placeholder file.
|
||||
- Optionally seed the first admin.
|
||||
- Configure optional S3 upload client.
|
||||
- Register all HTTP middleware and routes.
|
||||
- Start `http.ListenAndServe`.
|
||||
|
||||
### `internal/config`
|
||||
|
||||
`config.Load()` reads env vars and fills defaults. It returns a `Config` struct.
|
||||
|
||||
Important pattern:
|
||||
|
||||
```go
|
||||
addr := os.Getenv("HTTP_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
```
|
||||
|
||||
This means env vars are optional in local development.
|
||||
|
||||
### `internal/db`
|
||||
|
||||
`db.Connect(ctx, databaseURL)` parses `DATABASE_URL`, creates a pgx connection pool, then pings the DB.
|
||||
|
||||
The app shares this pool for all requests.
|
||||
|
||||
### `internal/auth`
|
||||
|
||||
Two JWT token types exist:
|
||||
|
||||
- Admin token: issuer `ark-admin`, includes `admin_id` and email.
|
||||
- Wallet user token: issuer `ark-user`, includes wallet address and role `user`.
|
||||
|
||||
Do not mix these. Admin middleware only accepts admin tokens.
|
||||
|
||||
### `internal/handlers`
|
||||
|
||||
Handlers are grouped by topic:
|
||||
|
||||
- `public.go`: public category/resource list/detail, search logs, counters.
|
||||
- `admin.go`: admin login/dashboard/resource CRUD/tag replacement.
|
||||
- `wallet_auth.go`: wallet nonce and signature verification.
|
||||
- `upload.go`: admin file upload.
|
||||
- `middleware.go`: admin auth middleware.
|
||||
- `context.go`: stores DB pool in request context.
|
||||
- `util.go`: shared JSON helpers.
|
||||
|
||||
## Database model summary
|
||||
|
||||
Main tables from `migrations/001_init.sql`:
|
||||
|
||||
- `admins`: admin email + bcrypt password hash.
|
||||
- `categories`: resource categories with multilingual names.
|
||||
- `resources`: library items; can be ppt/image/video/link/etc.
|
||||
- `tags`: reusable tag names.
|
||||
- `resource_tags`: many-to-many join table.
|
||||
- `search_logs`: saved public search terms.
|
||||
|
||||
Wallet auth table from migration/startup DDL:
|
||||
|
||||
- `wallet_auth_nonces`: one temporary nonce per wallet address.
|
||||
|
||||
## Route groups
|
||||
|
||||
### Public routes
|
||||
|
||||
These do not require auth:
|
||||
|
||||
- health: `/healthz`, `/health`
|
||||
- categories/resources under `/api`
|
||||
- wallet auth nonce/verify/me (me requires wallet JWT)
|
||||
- resource view/download/share/favorite counters
|
||||
|
||||
### Admin routes
|
||||
|
||||
`POST /api/admin/login` is public because it returns the admin token.
|
||||
|
||||
Everything in this group requires admin JWT:
|
||||
|
||||
- dashboard
|
||||
- search logs
|
||||
- resource list/get/create/update/delete
|
||||
- upload
|
||||
- admin categories list
|
||||
|
||||
## Upload behavior
|
||||
|
||||
Admin upload endpoint: `POST /api/admin/upload` with multipart field `file`.
|
||||
|
||||
If S3 is configured:
|
||||
|
||||
```text
|
||||
S3_BUCKET non-empty + AWS config loads => upload to S3
|
||||
```
|
||||
|
||||
Otherwise:
|
||||
|
||||
```text
|
||||
save file to UPLOAD_DIR and return /uploads/<filename>
|
||||
```
|
||||
|
||||
The max upload size is 512 MiB (`uploadMaxBytes`).
|
||||
|
||||
## Things to be careful with
|
||||
|
||||
- `migrations/001_init.sql` is not safe to run repeatedly on the same DB.
|
||||
- `JWT_SECRET` default is for dev only.
|
||||
- `RUN_WALLET_AUTH_SCHEMA=true` runs DDL at startup. Use false if the runtime DB user should not create tables.
|
||||
- The app does not auto-load `.env`.
|
||||
- Public resources are filtered to published + public; admin list sees all resources.
|
||||
@@ -1,281 +0,0 @@
|
||||
# Go Beginner Guide for This Backend
|
||||
|
||||
This is not a full Go course. It explains only the Go ideas you need to read this project.
|
||||
|
||||
## 1. How a Go project starts
|
||||
|
||||
This repo has a `go.mod` file:
|
||||
|
||||
```go
|
||||
module github.com/arkie/ark-database
|
||||
```
|
||||
|
||||
That module name is used in imports:
|
||||
|
||||
```go
|
||||
import "github.com/arkie/ark-database/internal/config"
|
||||
```
|
||||
|
||||
The executable starts at:
|
||||
|
||||
```text
|
||||
cmd/server/main.go
|
||||
```
|
||||
|
||||
In Go, a runnable program has:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
func main() {
|
||||
// program starts here
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Packages
|
||||
|
||||
Every `.go` file begins with a package name:
|
||||
|
||||
```go
|
||||
package handlers
|
||||
```
|
||||
|
||||
Files in the same folder usually share the same package and can call each other directly.
|
||||
|
||||
Example: files in `internal/handlers/` all say `package handlers`, so `public.go` can call `writeJSON` from `util.go`.
|
||||
|
||||
## 3. Imports
|
||||
|
||||
Go imports are explicit:
|
||||
|
||||
```go
|
||||
import (
|
||||
"net/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
```
|
||||
|
||||
If an import is unused, Go compilation fails. Run `gofmt -w .` after edits.
|
||||
|
||||
## 4. Structs
|
||||
|
||||
A struct is an object/data shape.
|
||||
|
||||
Example from `internal/config/config.go`:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Addr string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
}
|
||||
```
|
||||
|
||||
JSON field names are controlled by tags:
|
||||
|
||||
```go
|
||||
type ResourceDTO struct {
|
||||
CategoryID int `json:"categoryId"`
|
||||
}
|
||||
```
|
||||
|
||||
The Go field is `CategoryID`, but JSON output is `categoryId`.
|
||||
|
||||
## 5. Functions with errors
|
||||
|
||||
Go commonly returns `(value, error)`:
|
||||
|
||||
```go
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
Read this as:
|
||||
|
||||
1. Try connecting to DB.
|
||||
2. If `err` is not nil, stop.
|
||||
3. Otherwise use `pool`.
|
||||
|
||||
## 6. Short variable declaration `:=`
|
||||
|
||||
This creates a new variable:
|
||||
|
||||
```go
|
||||
cfg := config.Load()
|
||||
```
|
||||
|
||||
This assigns to an existing variable:
|
||||
|
||||
```go
|
||||
cfg = config.Load()
|
||||
```
|
||||
|
||||
Most code here uses `:=` inside functions.
|
||||
|
||||
## 7. HTTP handlers
|
||||
|
||||
A Go HTTP handler usually looks like:
|
||||
|
||||
```go
|
||||
func ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
// w writes response
|
||||
// r reads request
|
||||
}
|
||||
```
|
||||
|
||||
- `w` = response writer.
|
||||
- `r` = request.
|
||||
|
||||
Route registration in `main.go`:
|
||||
|
||||
```go
|
||||
r.Get("/categories", handlers.ListCategories)
|
||||
```
|
||||
|
||||
Because this is inside `r.Route("/api", ...)`, the full URL is:
|
||||
|
||||
```text
|
||||
GET /api/categories
|
||||
```
|
||||
|
||||
## 8. Middleware
|
||||
|
||||
Middleware wraps a request before it reaches the final handler.
|
||||
|
||||
Example in `main.go`:
|
||||
|
||||
```go
|
||||
r.Use(middleware.Logger)
|
||||
```
|
||||
|
||||
This logs requests.
|
||||
|
||||
Admin auth is also middleware:
|
||||
|
||||
```go
|
||||
r.Use(handlers.AdminAuth(cfg.JWTSecret))
|
||||
```
|
||||
|
||||
If token is bad, it returns `401 unauthorized` before reaching the admin handler.
|
||||
|
||||
## 9. Context
|
||||
|
||||
Context carries request-scoped values and cancellation.
|
||||
|
||||
This project stores the DB pool in request context:
|
||||
|
||||
```go
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return handlers.WithPool(next, pool)
|
||||
})
|
||||
```
|
||||
|
||||
Handlers retrieve it:
|
||||
|
||||
```go
|
||||
pool := poolFrom(r)
|
||||
```
|
||||
|
||||
So most handlers do not receive the DB pool as a direct function parameter.
|
||||
|
||||
## 10. Database queries
|
||||
|
||||
This project uses pgx.
|
||||
|
||||
One row:
|
||||
|
||||
```go
|
||||
err := pool.QueryRow(ctx, `SELECT id FROM admins WHERE email = $1`, email).Scan(&id)
|
||||
```
|
||||
|
||||
Many rows:
|
||||
|
||||
```go
|
||||
rows, err := pool.Query(ctx, `SELECT id, title FROM resources`)
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
rows.Scan(&id, &title)
|
||||
}
|
||||
```
|
||||
|
||||
PostgreSQL parameters use `$1`, `$2`, etc. This protects from SQL injection when used correctly.
|
||||
|
||||
## 11. Pointers and nil
|
||||
|
||||
You will see `*string` and `*time.Time`:
|
||||
|
||||
```go
|
||||
var pubAt *time.Time
|
||||
```
|
||||
|
||||
This allows SQL `NULL` to become Go `nil`.
|
||||
|
||||
Code checks before using it:
|
||||
|
||||
```go
|
||||
if pubAt != nil {
|
||||
s := pubAt.UTC().Format(time.RFC3339)
|
||||
dto.PublishedAt = &s
|
||||
}
|
||||
```
|
||||
|
||||
## 12. `defer`
|
||||
|
||||
`defer` runs later when the current function returns.
|
||||
|
||||
Examples:
|
||||
|
||||
```go
|
||||
defer pool.Close()
|
||||
defer rows.Close()
|
||||
defer r.Body.Close()
|
||||
```
|
||||
|
||||
Use it for cleanup.
|
||||
|
||||
## 13. JSON helpers in this project
|
||||
|
||||
Write JSON response:
|
||||
|
||||
```go
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
```
|
||||
|
||||
Read JSON request body:
|
||||
|
||||
```go
|
||||
var req loginReq
|
||||
if err := jsonDecode(r, &req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
## 14. Common edit workflow
|
||||
|
||||
After changing `.go` files:
|
||||
|
||||
```bash
|
||||
gofmt -w .
|
||||
go test ./...
|
||||
```
|
||||
|
||||
`go test ./...` also compiles all packages, even if there are no test files.
|
||||
|
||||
## 15. Recommended reading order
|
||||
|
||||
1. `README.md`
|
||||
2. `cmd/server/main.go`
|
||||
3. `internal/config/config.go`
|
||||
4. `internal/handlers/public.go`
|
||||
5. `internal/handlers/admin.go`
|
||||
6. `internal/handlers/wallet_auth.go`
|
||||
7. `migrations/001_init.sql`
|
||||
|
||||
When you see a function you do not understand, search for its definition:
|
||||
|
||||
```bash
|
||||
rg "func FunctionName" .
|
||||
```
|
||||
@@ -1,102 +0,0 @@
|
||||
# Local Development with OrbStack
|
||||
|
||||
Yes — this backend can run locally in OrbStack.
|
||||
|
||||
This repo includes `compose.yaml` with:
|
||||
|
||||
- `db`: PostgreSQL 16
|
||||
- `api`: the Go backend built from the local `Dockerfile`
|
||||
|
||||
## Quick start
|
||||
|
||||
From `Arkie-Library-Backend`:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then test:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
ok
|
||||
```
|
||||
|
||||
## Local URLs
|
||||
|
||||
| Service | URL |
|
||||
| --- | --- |
|
||||
| Backend API | `http://localhost:8080` |
|
||||
| Health check | `http://localhost:8080/healthz` |
|
||||
| PostgreSQL from Mac host | `localhost:5433` |
|
||||
| PostgreSQL from API container | `db:5432` |
|
||||
|
||||
The DB host port is `5433` to avoid conflicts with any local PostgreSQL already using `5432`.
|
||||
|
||||
## Default local admin
|
||||
|
||||
When the DB is empty, compose seeds an admin because `SEED_ADMIN=true` by default:
|
||||
|
||||
```text
|
||||
email: admin@ark.local
|
||||
password: admin123
|
||||
```
|
||||
|
||||
Override if needed:
|
||||
|
||||
```bash
|
||||
ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='your-password' docker compose up --build
|
||||
```
|
||||
|
||||
## Frontend connecting to local backend
|
||||
|
||||
In `Arkie-Library-Frontend`, use:
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
Then run your frontend dev server normally.
|
||||
|
||||
## Running Go locally but DB in OrbStack
|
||||
|
||||
For faster backend development, you can run only PostgreSQL in OrbStack and run Go directly on your Mac.
|
||||
|
||||
Start DB only:
|
||||
|
||||
```bash
|
||||
docker compose up db
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL='postgres://ark:ark@localhost:5433/arkdb?sslmode=disable'
|
||||
export HTTP_ADDR=':8080'
|
||||
export JWT_SECRET='local-dev-change-me'
|
||||
export CORS_ORIGINS='http://localhost:5173,http://localhost:3000'
|
||||
export SEED_ADMIN=true
|
||||
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
## Reset local database
|
||||
|
||||
This deletes local development data and reruns migrations on next start:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
- This is local-only and does not touch the production server.
|
||||
- `migrations/001_init.sql` runs only when the PostgreSQL volume is first created.
|
||||
- If you change migrations and want them reapplied, reset with `docker compose down -v`.
|
||||
- Do not use production `DATABASE_URL` for local development unless you intentionally want to modify production data.
|
||||
13
go.mod
13
go.mod
@@ -1,8 +1,11 @@
|
||||
module github.com/arkie/ark-database
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
||||
github.com/ethereum/go-ethereum v1.17.2
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
@@ -10,13 +13,12 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
golang.org/x/crypto v0.44.0
|
||||
golang.org/x/image v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
@@ -26,7 +28,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||
@@ -42,7 +43,7 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
10
go.sum
10
go.sum
@@ -110,12 +110,14 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
internal/handlers/category_i18n.go
Normal file
35
internal/handlers/category_i18n.go
Normal 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
|
||||
}
|
||||
36
internal/handlers/category_text.go
Normal file
36
internal/handlers/category_text.go
Normal 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,
|
||||
}
|
||||
}
|
||||
64
internal/handlers/post_schema.go
Normal file
64
internal/handlers/post_schema.go
Normal 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
|
||||
}
|
||||
78
internal/handlers/post_tags_catalog.go
Normal file
78
internal/handlers/post_tags_catalog.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package handlers
|
||||
|
||||
import "strings"
|
||||
|
||||
// Preset post tag slugs for admin multi-select (简体中文 label in NameZh).
|
||||
var presetPostTags = []struct {
|
||||
Slug string
|
||||
NameZh string
|
||||
}{
|
||||
{Slug: "official-recommended", NameZh: "官方推荐"},
|
||||
{Slug: "newcomer", NameZh: "新人必看"},
|
||||
{Slug: "week-featured", NameZh: "本周主推"},
|
||||
{Slug: "shareable", NameZh: "可转发"},
|
||||
{Slug: "downloadable", NameZh: "可下载"},
|
||||
{Slug: "image", NameZh: "图片"},
|
||||
{Slug: "video", NameZh: "视频"},
|
||||
{Slug: "ppt", NameZh: "PPT"},
|
||||
{Slug: "pdf", NameZh: "PDF"},
|
||||
{Slug: "copy", NameZh: "文案"},
|
||||
{Slug: "tutorial", NameZh: "教程"},
|
||||
{Slug: "announcement", NameZh: "公告"},
|
||||
{Slug: "event", NameZh: "活动"},
|
||||
{Slug: "poster", NameZh: "海报"},
|
||||
{Slug: "news", NameZh: "新闻"},
|
||||
{Slug: "materials", NameZh: "物料"},
|
||||
{Slug: "class", NameZh: "课堂"},
|
||||
{Slug: "tweet", NameZh: "推文"},
|
||||
}
|
||||
|
||||
var presetPostTagBySlug map[string]string
|
||||
var presetPostTagByName map[string]string
|
||||
|
||||
func init() {
|
||||
presetPostTagBySlug = make(map[string]string, len(presetPostTags))
|
||||
presetPostTagByName = make(map[string]string, len(presetPostTags))
|
||||
for _, t := range presetPostTags {
|
||||
presetPostTagBySlug[t.Slug] = t.NameZh
|
||||
presetPostTagByName[t.NameZh] = t.Slug
|
||||
// Traditional variants from legacy data
|
||||
switch t.Slug {
|
||||
case "official-recommended":
|
||||
presetPostTagByName["官方推薦"] = t.Slug
|
||||
case "shareable":
|
||||
presetPostTagByName["可轉發"] = t.Slug
|
||||
case "downloadable":
|
||||
presetPostTagByName["可下載"] = t.Slug
|
||||
case "image":
|
||||
presetPostTagByName["圖片"] = t.Slug
|
||||
case "video":
|
||||
presetPostTagByName["影片"] = t.Slug
|
||||
case "event":
|
||||
presetPostTagByName["活動"] = t.Slug
|
||||
case "poster":
|
||||
presetPostTagByName["海報"] = t.Slug
|
||||
case "news":
|
||||
presetPostTagByName["新聞"] = t.Slug
|
||||
case "class":
|
||||
presetPostTagByName["課堂"] = t.Slug
|
||||
}
|
||||
}
|
||||
// Legacy slugs from 001_init.sql
|
||||
presetPostTagBySlug["official"] = "官方推荐"
|
||||
presetPostTagByName["official"] = "official-recommended"
|
||||
}
|
||||
|
||||
func normalizePostTagSlug(raw string) (slug string, ok bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if _, ok := presetPostTagBySlug[raw]; ok {
|
||||
return raw, true
|
||||
}
|
||||
if slug, ok := presetPostTagByName[raw]; ok {
|
||||
return slug, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
99
internal/handlers/post_text.go
Normal file
99
internal/handlers/post_text.go
Normal file
@@ -0,0 +1,99 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// requestLangCode is the normalized locale from ?lang= or Accept-Language (zh, en, ja, …).
|
||||
func requestLangCode(r *http.Request) string {
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("lang"))
|
||||
if raw == "" {
|
||||
raw = r.Header.Get("Accept-Language")
|
||||
}
|
||||
raw = strings.TrimSpace(strings.Split(raw, ",")[0])
|
||||
if raw == "" {
|
||||
return "zh"
|
||||
}
|
||||
return translateNormalizePostLang(raw)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
121
internal/handlers/post_types.go
Normal file
121
internal/handlers/post_types.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Valid post_type values (Browse filter chips; inferred on admin save).
|
||||
var validPostTypes = map[string]bool{
|
||||
"image": true, "video": true, "music": true, "ppt": true, "pdf": true,
|
||||
"link": true, "text": true, "archive": true,
|
||||
}
|
||||
|
||||
var postTextURLPattern = regexp.MustCompile(`https?://`)
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
// inferPostTypeFromContent picks post_type from attachments and text (admin no longer sends postType).
|
||||
func inferPostTypeFromContent(text string, attachments []attachmentInput) string {
|
||||
hasText := strings.TrimSpace(text) != ""
|
||||
if len(attachments) == 0 {
|
||||
if hasText && postTextURLPattern.MatchString(text) {
|
||||
return "link"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
|
||||
var image, video, music, ppt, pdf, archive bool
|
||||
for _, a := range attachments {
|
||||
img, vid, mus, p, pd, ar := attachmentPostTypeSignals(a)
|
||||
image = image || img
|
||||
video = video || vid
|
||||
music = music || mus
|
||||
ppt = ppt || p
|
||||
pdf = pdf || pd
|
||||
archive = archive || ar
|
||||
}
|
||||
|
||||
// Specific file types first; image beats video when both exist (e.g. multi-photo posts).
|
||||
switch {
|
||||
case ppt:
|
||||
return "ppt"
|
||||
case pdf && !image && !video:
|
||||
return "pdf"
|
||||
case archive && !image && !video && !music && !ppt && !pdf:
|
||||
return "archive"
|
||||
case music && !image && !video:
|
||||
return "music"
|
||||
case image:
|
||||
return "image"
|
||||
case video:
|
||||
return "video"
|
||||
case pdf:
|
||||
return "pdf"
|
||||
case archive:
|
||||
return "archive"
|
||||
case music:
|
||||
return "music"
|
||||
}
|
||||
if hasText && postTextURLPattern.MatchString(text) {
|
||||
return "link"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
|
||||
func attachmentPostTypeSignals(a attachmentInput) (image, video, music, ppt, pdf, archive bool) {
|
||||
kind := strings.ToLower(strings.TrimSpace(a.Kind))
|
||||
mime := strings.ToLower(strings.TrimSpace(a.Mime))
|
||||
fn := strings.ToLower(strings.TrimSpace(a.Filename))
|
||||
if kind == "" && mime != "" {
|
||||
kind = classifyAttachmentKind(mime, fn)
|
||||
}
|
||||
|
||||
if kind == "video" || strings.HasPrefix(mime, "video/") {
|
||||
video = true
|
||||
}
|
||||
if kind == "image" || strings.HasPrefix(mime, "image/") {
|
||||
image = true
|
||||
}
|
||||
if strings.HasPrefix(mime, "audio/") ||
|
||||
strings.HasSuffix(fn, ".mp3") || strings.HasSuffix(fn, ".wav") ||
|
||||
strings.HasSuffix(fn, ".m4a") || strings.HasSuffix(fn, ".flac") || strings.HasSuffix(fn, ".aac") {
|
||||
music = true
|
||||
}
|
||||
if strings.Contains(mime, "pdf") || strings.HasSuffix(fn, ".pdf") {
|
||||
pdf = true
|
||||
}
|
||||
if strings.Contains(mime, "presentation") ||
|
||||
strings.HasSuffix(fn, ".ppt") || strings.HasSuffix(fn, ".pptx") {
|
||||
ppt = true
|
||||
}
|
||||
if strings.HasSuffix(fn, ".zip") || strings.HasSuffix(fn, ".rar") ||
|
||||
strings.HasSuffix(fn, ".7z") || strings.Contains(mime, "zip") {
|
||||
archive = true
|
||||
}
|
||||
return
|
||||
}
|
||||
488
internal/handlers/posts_common.go
Normal file
488
internal/handlers/posts_common.go
Normal file
@@ -0,0 +1,488 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type AttachmentDTO struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
Mime string `json:"mime"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
DurationSec *int `json:"durationSec,omitempty"`
|
||||
PosterURL string `json:"posterUrl,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
}
|
||||
|
||||
type PostDTO struct {
|
||||
ID string `json:"id"`
|
||||
PostType string `json:"postType"`
|
||||
CategoryID int `json:"categoryId,omitempty"`
|
||||
CategorySlug string `json:"categorySlug,omitempty"`
|
||||
Language string `json:"language"` // UI locale from ?lang= (matches text selection)
|
||||
SourceLanguage string `json:"sourceLanguage,omitempty"` // DB source metadata (admin input language)
|
||||
Text string `json:"text,omitempty"`
|
||||
Localizations map[string]postLocalePayload `json:"localizations"`
|
||||
Attachments []AttachmentDTO `json:"attachments"`
|
||||
IsRecommended bool `json:"isRecommended"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type PostListResponse struct {
|
||||
Items []PostDTO `json:"items"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
type attachmentInput struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
Mime string `json:"mime"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Width *int `json:"width"`
|
||||
Height *int `json:"height"`
|
||||
DurationSec *int `json:"durationSec"`
|
||||
PosterURL string `json:"posterUrl"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type adminPost struct {
|
||||
ID string `json:"id"`
|
||||
PostType string `json:"postType"`
|
||||
CategoryID int `json:"categoryId,omitempty"`
|
||||
CategorySlug string `json:"categorySlug,omitempty"`
|
||||
Language string `json:"language"`
|
||||
Text string `json:"text"`
|
||||
Attachments []attachmentInput `json:"attachments"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
IsRecommended bool `json:"isRecommended"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *string `json:"publishedAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Localizations map[string]postLocalePayload `json:"localizations,omitempty"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
DownloadCount int `json:"downloadCount,omitempty"`
|
||||
}
|
||||
|
||||
const publicPostWhere = `p.status = 'published' AND p.is_public = TRUE AND (p.published_at IS NULL OR p.published_at <= NOW())`
|
||||
|
||||
func parsePublishedAtPtr(s *string) (*time.Time, error) {
|
||||
if s == nil || strings.TrimSpace(*s) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, strings.TrimSpace(*s))
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(*s))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
utc := t.UTC()
|
||||
return &utc, nil
|
||||
}
|
||||
|
||||
func resolvePostPublishedAt(pubIn *string, status string, existing *time.Time, isCreate bool) (*time.Time, error) {
|
||||
if pubIn != nil && strings.TrimSpace(*pubIn) != "" {
|
||||
return parsePublishedAtPtr(pubIn)
|
||||
}
|
||||
if status == "published" {
|
||||
if isCreate || existing == nil {
|
||||
t := time.Now().UTC()
|
||||
return &t, nil
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func formatTimePtr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func encodePostCursor(pub time.Time, id uuid.UUID) string {
|
||||
return pub.UTC().Format(time.RFC3339Nano) + "|" + id.String()
|
||||
}
|
||||
|
||||
func decodePostCursor(cursor string) (time.Time, uuid.UUID, error) {
|
||||
parts := strings.SplitN(cursor, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, uuid.Nil, fmt.Errorf("bad cursor")
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, parts[0])
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339, parts[0])
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, uuid.Nil, err
|
||||
}
|
||||
id, err := uuid.Parse(parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}, uuid.Nil, err
|
||||
}
|
||||
return t.UTC(), id, nil
|
||||
}
|
||||
|
||||
func postTypeFilterSQL(typ string, args *[]any) string {
|
||||
raw := strings.ToLower(strings.TrimSpace(typ))
|
||||
if raw == "" || raw == "all" {
|
||||
return ""
|
||||
}
|
||||
typ = normalizePostType(typ)
|
||||
*args = append(*args, typ)
|
||||
n := len(*args)
|
||||
// Primary: admin-selected post_type; legacy rows fall back via OR attachment heuristics.
|
||||
switch typ {
|
||||
case "image":
|
||||
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
|
||||
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'image')))`, n)
|
||||
case "video":
|
||||
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
|
||||
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.kind = 'video' OR pa.mime LIKE 'video/%%'))))`, n)
|
||||
case "music":
|
||||
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
|
||||
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.mime LIKE 'audio/%%' OR pa.filename ILIKE '%%.mp3' OR pa.filename ILIKE '%%.wav' OR pa.filename ILIKE '%%.m4a'))))`, n)
|
||||
case "pdf":
|
||||
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
|
||||
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%%pdf%%' OR pa.filename ILIKE '%%.pdf')))`, n)
|
||||
case "ppt":
|
||||
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
|
||||
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%%presentation%%' OR pa.filename ILIKE '%%.ppt%%' OR pa.filename ILIKE '%%.pptx%%')))`, n)
|
||||
case "archive":
|
||||
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
|
||||
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.filename ILIKE '%%.zip%%' OR pa.filename ILIKE '%%.rar%%' OR pa.filename ILIKE '%%.7z%%')))`, n)
|
||||
case "text":
|
||||
return fmt.Sprintf(` AND p.post_type = $%d`, n)
|
||||
case "link":
|
||||
return fmt.Sprintf(` AND (p.post_type = $%d OR (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ms ~* 'https?://'))`, n)
|
||||
default:
|
||||
return fmt.Sprintf(` AND p.post_type = $%d`, n)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePostLangFilter(lang string) string {
|
||||
lang = strings.TrimSpace(lang)
|
||||
if lang == "" {
|
||||
return ""
|
||||
}
|
||||
return translateNormalizePostLang(lang)
|
||||
}
|
||||
|
||||
func translateNormalizePostLang(lang string) string {
|
||||
lang = strings.ToLower(lang)
|
||||
switch {
|
||||
case strings.HasPrefix(lang, "zh"):
|
||||
return "zh"
|
||||
case strings.HasPrefix(lang, "en"):
|
||||
return "en"
|
||||
case strings.HasPrefix(lang, "ja"):
|
||||
return "ja"
|
||||
case strings.HasPrefix(lang, "ko"):
|
||||
return "ko"
|
||||
case strings.HasPrefix(lang, "vi"):
|
||||
return "vi"
|
||||
case lang == "id", strings.HasPrefix(lang, "in"):
|
||||
return "id"
|
||||
case strings.HasPrefix(lang, "ms"):
|
||||
return "ms"
|
||||
default:
|
||||
return lang
|
||||
}
|
||||
}
|
||||
|
||||
func validateAdminPost(ap *adminPost) error {
|
||||
ap.PostType = inferPostTypeFromContent(ap.Text, ap.Attachments)
|
||||
if !validPostTypes[ap.PostType] {
|
||||
ap.PostType = "text"
|
||||
}
|
||||
if len(ap.Attachments) > maxPostAttachments {
|
||||
return fmt.Errorf("too many attachments (max %d)", maxPostAttachments)
|
||||
}
|
||||
hasText := strings.TrimSpace(ap.Text) != ""
|
||||
hasAtt := len(ap.Attachments) > 0
|
||||
if ap.Status == "published" && !hasText && !hasAtt {
|
||||
return fmt.Errorf("published post requires text and/or at least one attachment")
|
||||
}
|
||||
if ap.Status == "published" && ap.CategoryID <= 0 {
|
||||
return fmt.Errorf("published post requires categoryId")
|
||||
}
|
||||
for i, a := range ap.Attachments {
|
||||
if strings.TrimSpace(a.URL) == "" {
|
||||
return fmt.Errorf("attachment %d: url required", i)
|
||||
}
|
||||
k := strings.TrimSpace(a.Kind)
|
||||
if k != "image" && k != "video" && k != "document" {
|
||||
return fmt.Errorf("attachment %d: invalid kind", i)
|
||||
}
|
||||
}
|
||||
if ap.Status == "" {
|
||||
ap.Status = "draft"
|
||||
}
|
||||
normalized, err := normalizeAdminPostTags(ap.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ap.Tags = normalized
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAttachmentsByPostIDs(ctx context.Context, pool *pgxpool.Pool, ids []uuid.UUID) (map[uuid.UUID][]AttachmentDTO, error) {
|
||||
out := make(map[uuid.UUID][]AttachmentDTO)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT id, post_id, kind, url, mime, filename, size_bytes, width, height, duration_sec,
|
||||
COALESCE(poster_url,''), COALESCE(thumbnail_url,''), sort_order
|
||||
FROM post_attachments WHERE post_id = ANY($1)
|
||||
ORDER BY post_id, sort_order ASC, id ASC`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var aid, pid uuid.UUID
|
||||
var a AttachmentDTO
|
||||
var kind, url, mime, filename string
|
||||
var size int64
|
||||
var w, h, dur *int
|
||||
var poster, thumb string
|
||||
var sort int
|
||||
if err := rows.Scan(&aid, &pid, &kind, &url, &mime, &filename, &size, &w, &h, &dur, &poster, &thumb, &sort); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ID = aid.String()
|
||||
a.Kind = kind
|
||||
a.URL = url
|
||||
a.Mime = mime
|
||||
a.Filename = filename
|
||||
a.SizeBytes = size
|
||||
a.Width = w
|
||||
a.Height = h
|
||||
a.DurationSec = dur
|
||||
a.PosterURL = poster
|
||||
a.ThumbnailURL = thumb
|
||||
out[pid] = append(out[pid], a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadPostTagNames(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, lang string) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT t.name, COALESCE(t.name_en,''), COALESCE(t.name_ja,''), COALESCE(t.name_ko,''),
|
||||
COALESCE(t.name_vi,''), COALESCE(t.name_id,''), COALESCE(t.name_ms,''), t.slug
|
||||
FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.post_id = $1 ORDER BY t.slug`, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var name, en, ja, ko, vi, id, ms, slug string
|
||||
if err := rows.Scan(&name, &en, &ja, &ko, &vi, &id, &ms, &slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, tagDisplayName(name, en, ja, ko, vi, id, ms, lang))
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
func loadPostTagNamesByPostIDs(ctx context.Context, pool *pgxpool.Pool, ids []uuid.UUID, lang string) (map[uuid.UUID][]string, error) {
|
||||
out := make(map[uuid.UUID][]string)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT pt.post_id, t.name, COALESCE(t.name_en,''), COALESCE(t.name_ja,''), COALESCE(t.name_ko,''),
|
||||
COALESCE(t.name_vi,''), COALESCE(t.name_id,''), COALESCE(t.name_ms,'')
|
||||
FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.post_id = ANY($1)
|
||||
ORDER BY pt.post_id, t.slug`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pid uuid.UUID
|
||||
var name, en, ja, ko, vi, idLoc, ms string
|
||||
if err := rows.Scan(&pid, &name, &en, &ja, &ko, &vi, &idLoc, &ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[pid] = append(out[pid], tagDisplayName(name, en, ja, ko, vi, idLoc, ms, lang))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func replacePostTags(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, tagSlugs []string) error {
|
||||
_, err := pool.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, slug := range tagSlugs {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
var tid int
|
||||
err := pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `INSERT INTO post_tags (post_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, postID, tid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replacePostAttachments(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, inputs []attachmentInput) error {
|
||||
_, err := pool.Exec(ctx, `DELETE FROM post_attachments WHERE post_id = $1`, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, in := range inputs {
|
||||
kind := strings.TrimSpace(in.Kind)
|
||||
if kind == "" {
|
||||
kind = classifyAttachmentKind(in.Mime, in.Filename)
|
||||
}
|
||||
sort := in.SortOrder
|
||||
if sort == 0 {
|
||||
sort = i
|
||||
}
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO post_attachments (post_id, kind, url, mime, filename, size_bytes, width, height, duration_sec, poster_url, thumbnail_url, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
postID, kind, strings.TrimSpace(in.URL), nullIfEmptyStr(in.Mime, "application/octet-stream"),
|
||||
nullIfEmptyStr(in.Filename, "file"), in.SizeBytes,
|
||||
in.Width, in.Height, in.DurationSec,
|
||||
nullIfEmpty(in.PosterURL), nullIfEmpty(in.ThumbnailURL), sort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyAttachmentKind(mime, filename string) string {
|
||||
m := strings.ToLower(mime)
|
||||
if strings.HasPrefix(m, "image/") {
|
||||
return "image"
|
||||
}
|
||||
if strings.HasPrefix(m, "video/") {
|
||||
return "video"
|
||||
}
|
||||
fn := strings.ToLower(filename)
|
||||
if strings.HasSuffix(fn, ".jpg") || strings.HasSuffix(fn, ".jpeg") || strings.HasSuffix(fn, ".png") || strings.HasSuffix(fn, ".gif") || strings.HasSuffix(fn, ".webp") {
|
||||
return "image"
|
||||
}
|
||||
if strings.HasSuffix(fn, ".mp4") || strings.HasSuffix(fn, ".mov") || strings.HasSuffix(fn, ".webm") {
|
||||
return "video"
|
||||
}
|
||||
return "document"
|
||||
}
|
||||
|
||||
func postCategoryIDArg(id int) any {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func nullIfEmptyStr(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) {
|
||||
var dto PostDTO
|
||||
var texts postTextI18n
|
||||
var id uuid.UUID
|
||||
var catID *int
|
||||
var slug *string
|
||||
var sourceLang, postType string
|
||||
var pub, updated, created *time.Time
|
||||
err := row.Scan(
|
||||
&id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs,
|
||||
&sourceLang, &postType, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created,
|
||||
)
|
||||
if err != nil {
|
||||
return dto, texts, err
|
||||
}
|
||||
dto.ID = id.String()
|
||||
dto.PostType = normalizePostType(postType)
|
||||
if catID != nil {
|
||||
dto.CategoryID = *catID
|
||||
}
|
||||
if slug != nil {
|
||||
dto.CategorySlug = *slug
|
||||
}
|
||||
dto.SourceLanguage = sourceLang
|
||||
dto.Language = requestLangCode(r)
|
||||
dto.Text = texts.pick(r)
|
||||
dto.Localizations = texts.toLocalizations()
|
||||
if pub != nil {
|
||||
dto.PublishedAt = pub.UTC().Format(time.RFC3339)
|
||||
} else if created != nil {
|
||||
dto.PublishedAt = created.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if updated != nil {
|
||||
dto.UpdatedAt = updated.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if created != nil {
|
||||
dto.CreatedAt = created.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return dto, texts, nil
|
||||
}
|
||||
|
||||
func postSelectBase() string {
|
||||
return `
|
||||
SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), COALESCE(p.post_type,'text'),
|
||||
p.category_id, c.slug,
|
||||
p.is_recommended, p.published_at, p.updated_at, p.created_at
|
||||
FROM posts p
|
||||
LEFT JOIN categories c ON c.id = p.category_id`
|
||||
}
|
||||
|
||||
func postsFromClause() string {
|
||||
return `FROM posts p LEFT JOIN categories c ON c.id = p.category_id WHERE 1=1`
|
||||
}
|
||||
|
||||
func postLimitDef(r *http.Request, def, max int) int {
|
||||
limit := atoiDef(r.URL.Query().Get("limit"), def)
|
||||
if limit > max {
|
||||
limit = max
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func postLanguageFilterSQL(lang string, args *[]any) string {
|
||||
norm := normalizePostLangFilter(lang)
|
||||
if norm == "" {
|
||||
return ""
|
||||
}
|
||||
*args = append(*args, norm)
|
||||
return fmt.Sprintf(` AND p.language = $%d`, len(*args))
|
||||
}
|
||||
231
internal/handlers/posts_public.go
Normal file
231
internal/handlers/posts_public.go
Normal file
@@ -0,0 +1,231 @@
|
||||
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, requestLangCode(r))
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
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 OR t.name_en ILIKE $%d OR t.name_ja ILIKE $%d OR t.name_ko ILIKE $%d OR
|
||||
t.name_vi ILIKE $%d OR t.name_id ILIKE $%d OR t.name_ms ILIKE $%d)))`,
|
||||
n, n, n, n, n, n, 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{}
|
||||
dto.Tags = []string{}
|
||||
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
|
||||
}
|
||||
lang := requestLangCode(r)
|
||||
tagsByPost, err := loadPostTagNamesByPostIDs(r.Context(), poolFrom(r), ids, lang)
|
||||
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
|
||||
}
|
||||
if t, ok := tagsByPost[uid]; ok {
|
||||
list[i].Tags = t
|
||||
} else {
|
||||
list[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -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
|
||||
|
||||
57
internal/handlers/resource_schema.go
Normal file
57
internal/handlers/resource_schema.go
Normal 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
|
||||
}
|
||||
95
internal/handlers/resource_text.go
Normal file
95
internal/handlers/resource_text.go
Normal 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,
|
||||
}
|
||||
}
|
||||
100
internal/handlers/tag_i18n.go
Normal file
100
internal/handlers/tag_i18n.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func EnsureTagI18nSchema(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
_, err := pool.Exec(ctx, `
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_en TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ja TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ko TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_vi TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ms TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS is_preset BOOLEAN NOT NULL DEFAULT FALSE`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range presetPostTags {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO tags (name, slug, is_preset) VALUES ($1, $2, TRUE)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, is_preset = TRUE`,
|
||||
t.NameZh, t.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `UPDATE tags SET slug = 'official-recommended' WHERE slug = 'official'`)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAdminPostTags(tags []string) ([]string, error) {
|
||||
out := make([]string, 0, len(tags))
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range tags {
|
||||
slug, ok := normalizePostTagSlug(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown tag %q (use preset tags only)", strings.TrimSpace(raw))
|
||||
}
|
||||
if !seen[slug] {
|
||||
seen[slug] = true
|
||||
out = append(out, slug)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tagDisplayName(name, en, ja, ko, vi, id, ms, lang string) string {
|
||||
switch lang {
|
||||
case "en":
|
||||
if s := strings.TrimSpace(en); s != "" {
|
||||
return s
|
||||
}
|
||||
case "ja":
|
||||
if s := strings.TrimSpace(ja); s != "" {
|
||||
return s
|
||||
}
|
||||
case "ko":
|
||||
if s := strings.TrimSpace(ko); s != "" {
|
||||
return s
|
||||
}
|
||||
case "vi":
|
||||
if s := strings.TrimSpace(vi); s != "" {
|
||||
return s
|
||||
}
|
||||
case "id":
|
||||
if s := strings.TrimSpace(id); s != "" {
|
||||
return s
|
||||
}
|
||||
case "ms":
|
||||
if s := strings.TrimSpace(ms); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func loadPostTagSlugs(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT t.slug FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.post_id = $1 ORDER BY t.slug`, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var slugs []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slugs = append(slugs, s)
|
||||
}
|
||||
return slugs, rows.Err()
|
||||
}
|
||||
@@ -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, r, pub, hdr.Filename, name, ct, int64(len(data)), data, "s3")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,7 +113,55 @@ 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, r, "/uploads/"+name, hdr.Filename, name, ct, int64(len(data)), data, "local")
|
||||
}
|
||||
}
|
||||
|
||||
func writeUploadJSON(w http.ResponseWriter, r *http.Request, url, originalName, storedName, mime string, size int64, data []byte, storage string) {
|
||||
kind := classifyAttachmentKind(mime, originalName)
|
||||
if kind == "" {
|
||||
kind = classifyAttachmentKind(mime, storedName)
|
||||
}
|
||||
if k := strings.TrimSpace(r.FormValue("kind")); k == "image" || k == "document" || k == "video" {
|
||||
kind = k
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
migrations/005_resource_i18n_columns.sql
Normal file
27
migrations/005_resource_i18n_columns.sql
Normal 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;
|
||||
32
migrations/006_resource_seven_locales.sql
Normal file
32
migrations/006_resource_seven_locales.sql
Normal 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');
|
||||
21
migrations/007_category_seven_locales.sql
Normal file
21
migrations/007_category_seven_locales.sql
Normal 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
52
migrations/008_posts.sql
Normal 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)
|
||||
);
|
||||
6
migrations/009_posts_post_type.sql
Normal file
6
migrations/009_posts_post_type.sql
Normal 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);
|
||||
33
migrations/010_tags_i18n.sql
Normal file
33
migrations/010_tags_i18n.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Preset post tags: seven-locale names for public ?lang= display.
|
||||
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_en TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ja TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ko TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_vi TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS name_ms TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS is_preset BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Seed preset tags (name = 简体中文; slug stable for admin UI)
|
||||
INSERT INTO tags (name, slug, is_preset, name_en, name_ja, name_ko, name_vi, name_id, name_ms) VALUES
|
||||
('官方推荐', 'official-recommended', TRUE, '', '', '', '', '', ''),
|
||||
('新人必看', 'newcomer', TRUE, '', '', '', '', '', ''),
|
||||
('本周主推', 'week-featured', TRUE, '', '', '', '', '', ''),
|
||||
('可转发', 'shareable', TRUE, '', '', '', '', '', ''),
|
||||
('可下载', 'downloadable', TRUE, '', '', '', '', '', ''),
|
||||
('图片', 'image', TRUE, '', '', '', '', '', ''),
|
||||
('视频', 'video', TRUE, '', '', '', '', '', ''),
|
||||
('PPT', 'ppt', TRUE, '', '', '', '', '', ''),
|
||||
('PDF', 'pdf', TRUE, '', '', '', '', '', ''),
|
||||
('文案', 'copy', TRUE, '', '', '', '', '', ''),
|
||||
('教程', 'tutorial', TRUE, '', '', '', '', '', ''),
|
||||
('公告', 'announcement', TRUE, '', '', '', '', '', ''),
|
||||
('活动', 'event', TRUE, '', '', '', '', '', ''),
|
||||
('海报', 'poster', TRUE, '', '', '', '', '', ''),
|
||||
('新闻', 'news', TRUE, '', '', '', '', '', ''),
|
||||
('物料', 'materials', TRUE, '', '', '', '', '', ''),
|
||||
('课堂', 'class', TRUE, '', '', '', '', '', ''),
|
||||
('推文', 'tweet', TRUE, '', '', '', '', '', '')
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
is_preset = TRUE;
|
||||
Reference in New Issue
Block a user