Initial backend import

This commit is contained in:
TerryM
2026-05-16 00:18:22 +08:00
commit 141d92dc15
22 changed files with 2028 additions and 0 deletions

386
internal/handlers/admin.go Normal file
View File

@@ -0,0 +1,386 @@
package handlers
import (
"context"
"net/http"
"strings"
"time"
"github.com/arkie/ark-database/internal/auth"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
type loginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
func AdminLogin(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req loginReq
if err := jsonDecode(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
pool := poolFrom(r)
var id int
var hash string
err := pool.QueryRow(r.Context(), `SELECT id, password_hash FROM admins WHERE lower(email) = lower($1)`, strings.TrimSpace(req.Email)).Scan(&id, &hash)
if err != nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
tok, err := auth.SignAdmin(secret, id, req.Email, 7*24*time.Hour)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"token": tok})
}
}
func AdminDashboard(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
ctx := r.Context()
var total, published int
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM resources`).Scan(&total)
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM resources WHERE status = 'published'`).Scan(&published)
var views, downloads, favs, shares int64
_ = pool.QueryRow(ctx, `SELECT COALESCE(SUM(view_count),0), COALESCE(SUM(download_count),0), COALESCE(SUM(favorite_count),0), COALESCE(SUM(share_count),0) FROM resources`).Scan(&views, &downloads, &favs, &shares)
var todayNew int
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM resources WHERE created_at::date = (now() at time zone 'utc')::date`).Scan(&todayNew)
rows, err := pool.Query(ctx, `
SELECT r.id, r.title, r.download_count, r.favorite_count, r.view_count
FROM resources r WHERE r.status = 'published' ORDER BY (r.download_count + r.favorite_count) DESC LIMIT 8`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type hot struct {
ID string `json:"id"`
Title string `json:"title"`
Download int `json:"downloads"`
Favorite int `json:"favorites"`
View int `json:"views"`
}
hotList := make([]hot, 0)
for rows.Next() {
var id uuid.UUID
var h hot
if err := rows.Scan(&id, &h.Title, &h.Download, &h.Favorite, &h.View); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.ID = id.String()
hotList = append(hotList, h)
}
if err := rows.Err(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{
"totalResources": total,
"published": published,
"todayNew": todayNew,
"totalViews": views,
"totalDownloads": downloads,
"totalFavorites": favs,
"totalShares": shares,
"hotResources": hotList,
})
}
type adminResource struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Language string `json:"language"`
CategoryID int `json:"categoryId"`
CoverImage string `json:"coverImage"`
FileURL string `json:"fileUrl"`
PreviewURL string `json:"previewUrl"`
ExternalURL string `json:"externalUrl"`
BodyText string `json:"bodyText"`
BadgeLabel string `json:"badgeLabel"`
IsPublic bool `json:"isPublic"`
IsDownloadable bool `json:"isDownloadable"`
IsRecommended bool `json:"isRecommended"`
SortOrder int `json:"sortOrder"`
Status string `json:"status"`
PublishedAt *string `json:"publishedAt"`
Tags []string `json:"tags"`
ViewCount int `json:"viewCount"`
DownloadCount int `json:"downloadCount"`
}
func AdminListResources(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
page := atoiDef(r.URL.Query().Get("page"), 1)
limit := atoiDef(r.URL.Query().Get("limit"), 20)
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
var total int
if err := pool.QueryRow(r.Context(), `SELECT COUNT(*) FROM resources`).Scan(&total); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rows, err := pool.Query(r.Context(), `
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id,
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''),
COALESCE(r.badge_label,''), r.is_public, r.is_downloadable, r.is_recommended, r.sort_order, r.status, r.published_at, r.view_count, r.download_count
FROM resources r
ORDER BY r.updated_at DESC
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
list := make([]adminResource, 0)
for rows.Next() {
var ar adminResource
var id uuid.UUID
var pub *time.Time
if err := rows.Scan(&id, &ar.Title, &ar.Description, &ar.Type, &ar.Language, &ar.CategoryID, &ar.CoverImage, &ar.FileURL, &ar.PreviewURL, &ar.ExternalURL, &ar.BodyText,
&ar.BadgeLabel, &ar.IsPublic, &ar.IsDownloadable, &ar.IsRecommended, &ar.SortOrder, &ar.Status, &pub, &ar.ViewCount, &ar.DownloadCount); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ar.ID = id.String()
if pub != nil {
s := pub.UTC().Format(time.RFC3339)
ar.PublishedAt = &s
}
tags, _ := loadTagNames(r.Context(), pool, id)
ar.Tags = tags
list = append(list, ar)
}
if err := rows.Err(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"items": list, "page": page, "limit": limit, "total": total})
}
type searchLogRow struct {
ID int64 `json:"id"`
Query string `json:"query"`
CreatedAt string `json:"createdAt"`
}
func AdminSearchLogs(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
limit := atoiDef(r.URL.Query().Get("limit"), 200)
if limit > 500 {
limit = 500
}
rows, err := pool.Query(r.Context(), `SELECT id, query, created_at FROM search_logs ORDER BY id DESC LIMIT $1`, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
out := make([]searchLogRow, 0)
for rows.Next() {
var row searchLogRow
var created time.Time
if err := rows.Scan(&row.ID, &row.Query, &created); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
row.CreatedAt = created.UTC().Format(time.RFC3339)
out = append(out, row)
}
if err := rows.Err(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"items": out})
}
func AdminGetResource(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
rid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
row := pool.QueryRow(r.Context(), `
SELECT r.id, r.title, COALESCE(r.description,''), r.type, r.language, r.category_id,
COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.body_text,''),
COALESCE(r.badge_label,''), r.is_public, r.is_downloadable, r.is_recommended, r.sort_order, r.status, r.published_at, r.view_count, r.download_count
FROM resources r WHERE r.id = $1`, rid)
var ar adminResource
var rowID uuid.UUID
var pub *time.Time
if err := row.Scan(&rowID, &ar.Title, &ar.Description, &ar.Type, &ar.Language, &ar.CategoryID, &ar.CoverImage, &ar.FileURL, &ar.PreviewURL, &ar.ExternalURL, &ar.BodyText,
&ar.BadgeLabel, &ar.IsPublic, &ar.IsDownloadable, &ar.IsRecommended, &ar.SortOrder, &ar.Status, &pub, &ar.ViewCount, &ar.DownloadCount); err != nil {
if err == pgx.ErrNoRows {
http.NotFound(w, r)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ar.ID = rowID.String()
tags, _ := loadTagNames(r.Context(), pool, rowID)
ar.Tags = tags
if pub != nil {
s := pub.UTC().Format(time.RFC3339)
ar.PublishedAt = &s
}
writeJSON(w, ar)
}
func AdminCreateResource(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
var ar adminResource
if err := jsonDecode(r, &ar); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
adminCreateResource(w, r, pool, &ar)
}
func AdminUpdateResource(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
var ar adminResource
if err := jsonDecode(r, &ar); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
adminUpdateResource(w, r, pool, id, &ar)
}
func adminCreateResource(w http.ResponseWriter, r *http.Request, pool *pgxpool.Pool, ar *adminResource) {
ctx := r.Context()
var pub *time.Time
if ar.Status == "published" {
t := time.Now().UTC()
pub = &t
}
row := pool.QueryRow(ctx, `
INSERT INTO resources (category_id, title, description, type, language, cover_image, file_url, preview_url, external_url, body_text, badge_label,
is_public, is_downloadable, is_recommended, sort_order, status, published_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING id`,
ar.CategoryID, ar.Title, nullIfEmpty(ar.Description), ar.Type, ar.Language, nullIfEmpty(ar.CoverImage), nullIfEmpty(ar.FileURL), nullIfEmpty(ar.PreviewURL),
nullIfEmpty(ar.ExternalURL), nullIfEmpty(ar.BodyText), nullIfEmpty(ar.BadgeLabel), ar.IsPublic, ar.IsDownloadable, ar.IsRecommended, ar.SortOrder, ar.Status, pub,
)
var newID uuid.UUID
if err := row.Scan(&newID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = replaceTags(ctx, pool, newID, ar.Tags)
ar.ID = newID.String()
writeJSON(w, ar)
}
func adminUpdateResource(w http.ResponseWriter, r *http.Request, pool *pgxpool.Pool, id uuid.UUID, ar *adminResource) {
ctx := r.Context()
var pub *time.Time
if ar.PublishedAt != nil && *ar.PublishedAt != "" {
t, err := time.Parse(time.RFC3339, *ar.PublishedAt)
if err == nil {
pub = &t
}
} else if ar.Status == "published" {
// keep existing published_at if already set
var existing *time.Time
_ = pool.QueryRow(ctx, `SELECT published_at FROM resources WHERE id = $1`, id).Scan(&existing)
if existing == nil {
t := time.Now().UTC()
pub = &t
} else {
pub = existing
}
}
cmd, err := pool.Exec(ctx, `
UPDATE resources SET
category_id = $1, title = $2, description = $3, type = $4, language = $5,
cover_image = $6, file_url = $7, preview_url = $8, external_url = $9, body_text = $10, badge_label = $11,
is_public = $12, is_downloadable = $13, is_recommended = $14, sort_order = $15, status = $16, published_at = COALESCE($17, published_at),
updated_at = NOW()
WHERE id = $18`,
ar.CategoryID, ar.Title, nullIfEmpty(ar.Description), ar.Type, ar.Language, nullIfEmpty(ar.CoverImage), nullIfEmpty(ar.FileURL), nullIfEmpty(ar.PreviewURL),
nullIfEmpty(ar.ExternalURL), nullIfEmpty(ar.BodyText), nullIfEmpty(ar.BadgeLabel), ar.IsPublic, ar.IsDownloadable, ar.IsRecommended, ar.SortOrder, ar.Status, pub, id,
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if cmd.RowsAffected() == 0 {
http.NotFound(w, r)
return
}
_ = replaceTags(ctx, pool, id, ar.Tags)
writeJSON(w, map[string]any{"ok": true})
}
func nullIfEmpty(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
func replaceTags(ctx context.Context, pool *pgxpool.Pool, resourceID uuid.UUID, tags []string) error {
_, err := pool.Exec(ctx, `DELETE FROM resource_tags WHERE resource_id = $1`, resourceID)
if err != nil {
return err
}
for _, t := range tags {
t = strings.TrimSpace(t)
if t == "" {
continue
}
var tid int
slug := tagSlug(t)
err := pool.QueryRow(ctx, `INSERT INTO tags (name, slug) VALUES ($1, $2)
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name RETURNING id`, t, slug).Scan(&tid)
if err != nil {
_ = pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid)
}
_, _ = pool.Exec(ctx, `INSERT INTO resource_tags (resource_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, resourceID, tid)
}
return nil
}
func AdminDeleteResource(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
}
cmd, err := pool.Exec(r.Context(), `DELETE FROM resources WHERE id = $1`, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if cmd.RowsAffected() == 0 {
http.NotFound(w, r)
return
}
writeJSON(w, map[string]any{"ok": true})
}

View File

@@ -0,0 +1,40 @@
package handlers
import (
"context"
"net/http"
"strconv"
"github.com/jackc/pgx/v5/pgxpool"
)
type ctxKey string
const poolKey ctxKey = "pgpool"
func WithPool(next http.Handler, pool *pgxpool.Pool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), poolKey, pool)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func poolFrom(r *http.Request) *pgxpool.Pool {
return r.Context().Value(poolKey).(*pgxpool.Pool)
}
func parsePage(r *http.Request) (page, limit int) {
page = 1
limit = 20
if v := r.URL.Query().Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
page = n
}
}
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
limit = n
}
}
return page, limit
}

View File

@@ -0,0 +1,41 @@
package handlers
import (
"context"
"net/http"
"strings"
"github.com/arkie/ark-database/internal/auth"
)
type adminCtxKey string
const adminIDKey adminCtxKey = "admin_id"
func AdminAuth(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tok := strings.TrimSpace(h[7:])
claims, err := auth.ParseAdmin(secret, tok)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), adminIDKey, claims.AdminID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func adminIDFrom(r *http.Request) int {
v := r.Context().Value(adminIDKey)
if v == nil {
return 0
}
return v.(int)
}

451
internal/handlers/public.go Normal file
View File

@@ -0,0 +1,451 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type CategoryDTO struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
IconKey string `json:"iconKey"`
SortOrder int `json:"sortOrder"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
type ResourceDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
Language string `json:"language"`
CategoryID int `json:"categoryId"`
CategorySlug string `json:"categorySlug"`
CategoryName string `json:"categoryName"`
CoverImage string `json:"coverImage,omitempty"`
FileURL string `json:"fileUrl,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
ExternalURL string `json:"externalUrl,omitempty"`
BodyText string `json:"bodyText,omitempty"`
BadgeLabel string `json:"badgeLabel,omitempty"`
IsDownloadable bool `json:"isDownloadable"`
IsRecommended bool `json:"isRecommended"`
PublishedAt *string `json:"publishedAt,omitempty"`
UpdatedAt string `json:"updatedAt"`
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`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
out := make([]CategoryDTO, 0)
for rows.Next() {
var c CategoryDTO
var zhTW, zhCN, en *string
var desc *string
var updated time.Time
if err := rows.Scan(&c.ID, &c.Slug, &zhTW, &zhCN, &en, &desc, &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)
c.UpdatedAt = updated.UTC().Format(time.RFC3339)
out = append(out, c)
}
if err := rows.Err(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, out)
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
func ListResources(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
page := atoiDef(r.URL.Query().Get("page"), 1)
limit := atoiDef(r.URL.Query().Get("limit"), 20)
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
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 == "" {
sort = "latest"
}
order := "r.updated_at DESC"
switch sort {
case "published":
order = "r.published_at DESC NULLS LAST, r.updated_at DESC"
case "recommended":
order = "r.is_recommended DESC, r.sort_order DESC, r.updated_at DESC"
case "popular":
order = "(r.download_count + r.favorite_count + r.share_count) DESC, r.updated_at DESC"
}
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,''),
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`
args := []any{}
cond := ""
if catSlug != "" {
args = append(args, catSlug)
cond += " AND c.slug = $" + strconv.Itoa(len(args))
}
if typ != "" && typ != "all" {
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)
}
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
if tag != "" {
tagLower := strings.ToLower(tag)
args = append(args, tagLower)
i := strconv.Itoa(len(args))
cond += ` AND EXISTS (
SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id
WHERE rt.resource_id = r.id AND (lower(t.slug) = $` + i + ` OR lower(t.name) = $` + i + `))`
}
countSQL := `SELECT COUNT(*) FROM resources r JOIN categories c ON c.id = r.category_id WHERE r.status = 'published' AND r.is_public = TRUE` + cond
var total int
if err := pool.QueryRow(r.Context(), countSQL, args...).Scan(&total); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sqlStr := base + cond + " ORDER BY " + order + " LIMIT $" + strconv.Itoa(len(args)+1) + " OFFSET $" + strconv.Itoa(len(args)+2)
args = append(args, limit, offset)
rows, err := pool.Query(r.Context(), sqlStr, args...)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
items, err := readResourceRows(rows, r, pool)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"items": items, "page": page, "limit": limit, "total": total})
}
func ListRecommended(w http.ResponseWriter, r *http.Request) {
listFeatured(w, r, "r.is_recommended = TRUE", "r.sort_order DESC, r.updated_at DESC")
}
func ListLatest(w http.ResponseWriter, r *http.Request) {
listFeatured(w, r, "TRUE", "r.updated_at DESC")
}
func ListPopular(w http.ResponseWriter, r *http.Request) {
listFeatured(w, r, "TRUE", "(r.download_count + r.favorite_count + r.share_count) DESC, r.updated_at DESC")
}
func listFeatured(w http.ResponseWriter, r *http.Request, extra string, order string) {
pool := poolFrom(r)
limit := atoiDef(r.URL.Query().Get("limit"), 12)
if limit > 50 {
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,''),
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 + `)
ORDER BY ` + order + ` LIMIT $1`
args := []any{limit}
rows, err := pool.Query(r.Context(), sqlStr, args...)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
items, err := readResourceRows(rows, r, pool)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"items": items})
}
func GetResource(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
idStr := chi.URLParam(r, "id")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
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,''),
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)
dto, err := scanResourceRow(row, r, pool)
if err != nil {
if err == pgx.ErrNoRows {
http.NotFound(w, r)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, dto)
}
func RelatedResources(w http.ResponseWriter, r *http.Request) {
pool := poolFrom(r)
idStr := chi.URLParam(r, "id")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var catID int
if err := pool.QueryRow(r.Context(), `SELECT category_id FROM resources WHERE id = $1`, id).Scan(&catID); err != nil {
http.NotFound(w, r)
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,''),
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
ORDER BY r.updated_at DESC LIMIT 8`, catID, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
items, err := readResourceRows(rows, r, pool)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"items": items})
}
func LogSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
var body struct {
Query string `json:"query"`
}
if err := jsonDecode(r, &body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
q := strings.TrimSpace(body.Query)
if q == "" {
writeJSON(w, map[string]any{"ok": true})
return
}
pool := poolFrom(r)
_, _ = pool.Exec(r.Context(), `INSERT INTO search_logs (query) VALUES ($1)`, q)
writeJSON(w, map[string]any{"ok": true})
}
func IncrView(w http.ResponseWriter, r *http.Request) {
incrMetric(w, r, "view_count")
}
func IncrDownload(w http.ResponseWriter, r *http.Request) {
incrMetric(w, r, "download_count")
}
func IncrShare(w http.ResponseWriter, r *http.Request) {
incrMetric(w, r, "share_count")
}
func FavoriteDelta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
var body struct {
Add bool `json:"add"`
}
if err := jsonDecode(r, &body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
pool := poolFrom(r)
idStr := chi.URLParam(r, "id")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var q string
if body.Add {
q = `UPDATE resources SET favorite_count = favorite_count + 1 WHERE id = $1`
} else {
q = `UPDATE resources SET favorite_count = GREATEST(0, favorite_count - 1) WHERE id = $1`
}
if _, err := pool.Exec(r.Context(), q, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true})
}
func incrMetric(w http.ResponseWriter, r *http.Request, col string) {
pool := poolFrom(r)
idStr := chi.URLParam(r, "id")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
// col is whitelisted
switch col {
case "view_count", "download_count", "share_count":
default:
http.Error(w, "bad metric", http.StatusBadRequest)
return
}
_, err = pool.Exec(r.Context(), `UPDATE resources SET `+col+` = `+col+` + 1 WHERE id = $1`, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true})
}
func readResourceRows(rows pgx.Rows, r *http.Request, pool *pgxpool.Pool) ([]ResourceDTO, error) {
list := make([]ResourceDTO, 0)
for rows.Next() {
dto, err := scanResourceRow(rows, r, pool)
if err != nil {
return nil, err
}
list = append(list, dto)
}
return list, rows.Err()
}
func scanResourceRow(scanner interface {
Scan(dest ...any) error
}, r *http.Request, pool *pgxpool.Pool) (ResourceDTO, error) {
var dto ResourceDTO
var pubAt *time.Time
var updated time.Time
var zhTW, zhCN, en *string
var id uuid.UUID
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,
&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))
if pubAt != nil {
s := pubAt.UTC().Format(time.RFC3339)
dto.PublishedAt = &s
}
dto.UpdatedAt = updated.UTC().Format(time.RFC3339)
if pool != nil {
tags, err := loadTagNames(r.Context(), pool, id)
if err == nil {
dto.Tags = tags
}
}
return dto, nil
}
func loadTagNames(ctx context.Context, pool *pgxpool.Pool, id uuid.UUID) ([]string, error) {
rows, err := pool.Query(ctx, `SELECT t.name FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = $1 ORDER BY t.name`, id)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,13 @@
package handlers
import (
"fmt"
"hash/fnv"
"strings"
)
func tagSlug(name string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(strings.TrimSpace(name)))
return fmt.Sprintf("tag-%08x", h.Sum32())
}

115
internal/handlers/upload.go Normal file
View File

@@ -0,0 +1,115 @@
package handlers
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
const uploadMaxBytes = 512 << 20 // 512 MiB upper bound per upload
// UploadDeps configures admin multipart upload (local disk and/or S3).
type UploadDeps struct {
LocalDir string
MaxMultipartMem int64
S3 *s3.Client
S3Bucket string
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
}
func UploadFile(d UploadDeps) http.HandlerFunc {
maxMem := d.MaxMultipartMem
if maxMem <= 0 {
maxMem = 32 << 20
}
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(maxMem); err != nil {
http.Error(w, "multipart required", http.StatusBadRequest)
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, "file field required", http.StatusBadRequest)
return
}
defer file.Close()
ext := filepath.Ext(hdr.Filename)
if ext == "" {
ext = ".bin"
}
name := uuid.NewString() + ext
data, err := io.ReadAll(io.LimitReader(file, uploadMaxBytes+1))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(data) > uploadMaxBytes {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
return
}
ct := hdr.Header.Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(data)
}
if d.S3 != nil && strings.TrimSpace(d.S3Bucket) != "" {
pfx := strings.Trim(strings.TrimSpace(d.S3Prefix), "/")
if pfx == "" {
pfx = "uploads"
}
key := pfx + "/" + name
ctx := r.Context()
_, err := d.S3.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(d.S3Bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
ContentType: aws.String(ct),
})
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"})
return
}
dst := filepath.Join(d.LocalDir, name)
if err := os.MkdirAll(d.LocalDir, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
out, err := os.Create(dst)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer out.Close()
if _, err := out.Write(data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"url": "/uploads/" + name, "filename": name, "storage": "local"})
}
}
func publicObjectURL(base, bucket, region, key string) string {
base = strings.TrimSpace(base)
if base != "" {
return strings.TrimSuffix(base, "/") + "/" + key
}
// Virtual-hostedstyle URL (works for most buckets; use S3_PUBLIC_BASE_URL if not).
return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, region, key)
}

32
internal/handlers/util.go Normal file
View File

@@ -0,0 +1,32 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"strconv"
)
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
func atoiDef(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
return def
}
return n
}
func jsonDecode(r *http.Request, v any) error {
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
return dec.Decode(v)
}

View File

@@ -0,0 +1,168 @@
package handlers
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"regexp"
"strings"
"time"
"github.com/arkie/ark-database/internal/auth"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/jackc/pgx/v5/pgxpool"
)
var ethAddrRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`)
func EnsureWalletAuthSchema(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS wallet_auth_nonces (
address TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_wallet_auth_nonces_expires ON wallet_auth_nonces(expires_at);
`)
return err
}
func normalizeAddr(a string) string {
return strings.ToLower(strings.TrimSpace(a))
}
type walletNonceReq struct {
Address string `json:"address"`
}
type walletVerifyReq struct {
Address string `json:"address"`
Message string `json:"message"`
Signature string `json:"signature"`
}
func WalletNonce() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req walletNonceReq
if err := jsonDecode(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
addr := normalizeAddr(req.Address)
if !ethAddrRe.MatchString(addr) {
http.Error(w, "invalid address", http.StatusBadRequest)
return
}
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
http.Error(w, "rng", http.StatusInternalServerError)
return
}
nonce := hex.EncodeToString(b[:])
pool := poolFrom(r)
ctx := r.Context()
_, _ = pool.Exec(ctx, `DELETE FROM wallet_auth_nonces WHERE expires_at < NOW()`)
_, err := pool.Exec(ctx, `
INSERT INTO wallet_auth_nonces (address, nonce, expires_at) VALUES ($1, $2, NOW() + INTERVAL '15 minutes')
ON CONFLICT (address) DO UPDATE SET nonce = EXCLUDED.nonce, expires_at = EXCLUDED.expires_at`,
addr, nonce)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
checksum := common.HexToAddress(addr).Hex()
message := "ARK Database — wallet sign-in\n\n" +
"Wallet: " + checksum + "\n" +
"One-time code: " + nonce + "\n\n" +
"Sign this message to log in. No transaction or gas fee."
writeJSON(w, map[string]any{"nonce": nonce, "message": message})
}
}
func WalletVerify(jwtSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req walletVerifyReq
if err := jsonDecode(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
addr := normalizeAddr(req.Address)
if !ethAddrRe.MatchString(addr) {
http.Error(w, "invalid address", http.StatusBadRequest)
return
}
sig := strings.TrimSpace(req.Signature)
if !strings.HasPrefix(sig, "0x") {
sig = "0x" + sig
}
msg := req.Message
if msg == "" {
http.Error(w, "message required", http.StatusBadRequest)
return
}
pool := poolFrom(r)
ctx := r.Context()
var storedNonce string
err := pool.QueryRow(ctx, `SELECT nonce FROM wallet_auth_nonces WHERE address = $1 AND expires_at > NOW()`, addr).Scan(&storedNonce)
if err != nil {
http.Error(w, "nonce expired or missing — request a new code", http.StatusUnauthorized)
return
}
if !strings.Contains(msg, storedNonce) {
http.Error(w, "message does not match nonce", http.StatusUnauthorized)
return
}
recovered, err := recoverPersonalSign(msg, sig)
if err != nil {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
if normalizeAddr(recovered.Hex()) != addr {
http.Error(w, "signer mismatch", http.StatusUnauthorized)
return
}
_, _ = pool.Exec(ctx, `DELETE FROM wallet_auth_nonces WHERE address = $1`, addr)
tok, err := auth.SignUserWallet(jwtSecret, common.HexToAddress(addr).Hex(), 30*24*time.Hour)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"token": tok, "wallet": common.HexToAddress(addr).Hex()})
}
}
func recoverPersonalSign(message, sigHex string) (common.Address, error) {
sig, err := hex.DecodeString(strings.TrimPrefix(sigHex, "0x"))
if err != nil || len(sig) != 65 {
return common.Address{}, err
}
if sig[64] >= 27 {
sig[64] -= 27
}
hash := accounts.TextHash([]byte(message))
pub, err := crypto.SigToPub(hash, sig)
if err != nil {
return common.Address{}, err
}
return crypto.PubkeyToAddress(*pub), nil
}
func WalletMe(jwtSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(strings.ToLower(h), "bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tok := strings.TrimSpace(h[7:])
claims, err := auth.ParseUserWallet(jwtSecret, tok)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]any{"wallet": claims.Wallet, "role": claims.Role})
}
}