Initial backend import
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
**/uploads
|
||||
**/.git
|
||||
/tmp
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Build artifacts / binaries
|
||||
/bin/
|
||||
/dist/
|
||||
/tmp/
|
||||
*.test
|
||||
*.out
|
||||
*.exe
|
||||
|
||||
# Runtime uploads / generated files
|
||||
uploads/
|
||||
**/uploads/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/server /app/server
|
||||
COPY static /app/static
|
||||
RUN mkdir -p /app/uploads && cp /app/static/placeholder-cover.svg /app/uploads/placeholder-cover.svg
|
||||
ENV UPLOAD_DIR=/app/uploads
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/server"]
|
||||
171
cmd/server/main.go
Normal file
171
cmd/server/main.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/arkie/ark-database/internal/config"
|
||||
"github.com/arkie/ark-database/internal/db"
|
||||
"github.com/arkie/ark-database/internal/handlers"
|
||||
"github.com/arkie/ark-database/internal/seed"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
ctx := context.Background()
|
||||
|
||||
dbCtx, dbCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
pool, err := db.Connect(dbCtx, cfg.DatabaseURL)
|
||||
dbCancel()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if cfg.RunWalletAuthSchema {
|
||||
if err := handlers.EnsureWalletAuthSchema(ctx, pool); err != nil {
|
||||
log.Fatalf("wallet auth schema: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Print("RUN_WALLET_AUTH_SCHEMA=false: skipping wallet_auth_* DDL at startup")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Placeholder asset for demo DB rows
|
||||
_ = copyIfMissing("static/placeholder-cover.svg", filepath.Join(cfg.UploadDir, "placeholder-cover.svg"))
|
||||
|
||||
if cfg.SeedAdmin {
|
||||
if err := seed.EnsureAdmin(ctx, pool, cfg.AdminEmail, cfg.AdminPass); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var s3Client *s3.Client
|
||||
if cfg.S3Bucket != "" {
|
||||
// Avoid blocking forever when IMDS is unreachable from the container (e.g. IMDSv2 hop limit 1 on EC2).
|
||||
s3Ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
awscfg, err := awsconfig.LoadDefaultConfig(s3Ctx, awsconfig.WithRegion(cfg.AWSRegion))
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("S3 disabled: aws config failed (uploads stay local): %v", err)
|
||||
} else {
|
||||
s3Client = s3.NewFromConfig(awscfg)
|
||||
log.Printf("S3 uploads enabled bucket=%s region=%s prefix=%s", cfg.S3Bucket, cfg.AWSRegion, cfg.S3UploadPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
uploadDeps := handlers.UploadDeps{
|
||||
LocalDir: cfg.UploadDir,
|
||||
MaxMultipartMem: cfg.UploadMaxMem,
|
||||
S3: s3Client,
|
||||
S3Bucket: cfg.S3Bucket,
|
||||
AWSRegion: cfg.AWSRegion,
|
||||
S3Prefix: cfg.S3UploadPrefix,
|
||||
S3PublicBase: cfg.S3PublicBase,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Minute))
|
||||
|
||||
if len(cfg.CORSOrigins) > 0 {
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: cfg.CORSOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
}
|
||||
|
||||
fs := http.FileServer(http.Dir(cfg.UploadDir))
|
||||
r.Handle("/uploads/*", http.StripPrefix("/uploads/", fs))
|
||||
|
||||
healthOK := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
r.Get("/healthz", healthOK)
|
||||
r.Get("/health", healthOK)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return handlers.WithPool(next, pool)
|
||||
})
|
||||
|
||||
r.Get("/categories", handlers.ListCategories)
|
||||
r.Get("/resources", handlers.ListResources)
|
||||
r.Get("/resources/recommended", handlers.ListRecommended)
|
||||
r.Get("/resources/latest", handlers.ListLatest)
|
||||
r.Get("/resources/popular", handlers.ListPopular)
|
||||
r.Get("/resources/{id}", handlers.GetResource)
|
||||
r.Get("/resources/{id}/related", handlers.RelatedResources)
|
||||
r.Post("/search-log", handlers.LogSearch)
|
||||
r.Post("/auth/wallet/nonce", handlers.WalletNonce())
|
||||
r.Post("/auth/wallet/verify", handlers.WalletVerify(cfg.JWTSecret))
|
||||
r.Get("/auth/wallet/me", handlers.WalletMe(cfg.JWTSecret))
|
||||
r.Post("/resources/{id}/view", handlers.IncrView)
|
||||
r.Post("/resources/{id}/download", handlers.IncrDownload)
|
||||
r.Post("/resources/{id}/share", handlers.IncrShare)
|
||||
r.Post("/resources/{id}/favorite", handlers.FavoriteDelta)
|
||||
|
||||
r.Post("/admin/login", handlers.AdminLogin(cfg.JWTSecret))
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(handlers.AdminAuth(cfg.JWTSecret))
|
||||
r.Get("/admin/dashboard", handlers.AdminDashboard)
|
||||
r.Get("/admin/search-logs", handlers.AdminSearchLogs)
|
||||
r.Get("/admin/resources", handlers.AdminListResources)
|
||||
r.Get("/admin/resources/{id}", handlers.AdminGetResource)
|
||||
r.Post("/admin/resources", handlers.AdminCreateResource)
|
||||
r.Put("/admin/resources/{id}", handlers.AdminUpdateResource)
|
||||
r.Delete("/admin/resources/{id}", handlers.AdminDeleteResource)
|
||||
r.Post("/admin/upload", handlers.UploadFile(uploadDeps))
|
||||
r.Get("/admin/categories", handlers.ListCategories)
|
||||
})
|
||||
})
|
||||
|
||||
log.Printf("listening on %s", cfg.Addr)
|
||||
if err := http.ListenAndServe(cfg.Addr, r); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func copyIfMissing(srcRel, dst string) error {
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
exe, _ := os.Executable()
|
||||
base := filepath.Dir(exe)
|
||||
candidates := []string{
|
||||
filepath.Join(base, srcRel),
|
||||
filepath.Join("..", srcRel),
|
||||
filepath.Join(".", srcRel),
|
||||
}
|
||||
var data []byte
|
||||
var err error
|
||||
for _, c := range candidates {
|
||||
data, err = os.ReadFile(c)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0o644)
|
||||
}
|
||||
48
go.mod
Normal file
48
go.mod
Normal file
@@ -0,0 +1,48 @@
|
||||
module github.com/arkie/ark-database
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
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
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
golang.org/x/crypto v0.44.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
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
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
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||
github.com/aws/smithy-go v1.25.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.20.0 // indirect
|
||||
github.com/consensys/gnark-crypto v0.18.1 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
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/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
)
|
||||
124
go.sum
Normal file
124
go.sum
Normal file
@@ -0,0 +1,124 @@
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI=
|
||||
github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI=
|
||||
github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
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/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=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
45
internal/auth/jwt.go
Normal file
45
internal/auth/jwt.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
AdminID int `json:"admin_id"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func SignAdmin(secret string, adminID int, email string, ttl time.Duration) (string, error) {
|
||||
claims := Claims{
|
||||
AdminID: adminID,
|
||||
Email: email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: IssuerAdmin,
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ParseAdmin(secret, token string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Valid || claims.Issuer != IssuerAdmin || claims.AdminID <= 0 {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
49
internal/auth/jwt_user.go
Normal file
49
internal/auth/jwt_user.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const IssuerAdmin = "ark-admin"
|
||||
const IssuerUserWallet = "ark-user"
|
||||
|
||||
type UserWalletClaims struct {
|
||||
Wallet string `json:"wallet"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func SignUserWallet(secret, wallet string, ttl time.Duration) (string, error) {
|
||||
claims := UserWalletClaims{
|
||||
Wallet: wallet,
|
||||
Role: "user",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: IssuerUserWallet,
|
||||
Subject: wallet,
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims)
|
||||
return t.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ParseUserWallet(secret, token string) (*UserWalletClaims, error) {
|
||||
claims := &UserWalletClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Valid || claims.Issuer != IssuerUserWallet {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
112
internal/config/config.go
Normal file
112
internal/config/config.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DatabaseURL string
|
||||
// RunWalletAuthSchema: when true, API startup runs CREATE TABLE for wallet_auth_* (use a DDL-capable DB user).
|
||||
// Set false on read-only / public API hosts after schema has been applied elsewhere (e.g. admin/migrate host).
|
||||
RunWalletAuthSchema bool
|
||||
JWTSecret string
|
||||
UploadDir string
|
||||
UploadMaxMem int64 // passed to ParseMultipartForm (file parts buffer before spilling to disk)
|
||||
SeedAdmin bool
|
||||
AdminEmail string
|
||||
AdminPass string
|
||||
CORSOrigins []string
|
||||
// S3 (optional). When S3Bucket is set, admin uploads go to S3; use AWS SDK default credentials (env or IAM).
|
||||
S3Bucket string
|
||||
AWSRegion string
|
||||
S3UploadPrefix string
|
||||
S3PublicBase string // optional CDN / website endpoint base URL for returned object URLs
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
origins := os.Getenv("CORS_ORIGINS")
|
||||
var cors []string
|
||||
if origins != "" {
|
||||
for _, o := range strings.Split(origins, ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o != "" {
|
||||
cors = append(cors, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
addr := os.Getenv("HTTP_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
db := os.Getenv("DATABASE_URL")
|
||||
if db == "" {
|
||||
db = "postgres://ark:ark@localhost:5432/arkdb?sslmode=disable"
|
||||
}
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
secret = "dev-insecure-change-me"
|
||||
}
|
||||
upload := os.Getenv("UPLOAD_DIR")
|
||||
if upload == "" {
|
||||
upload = "./uploads"
|
||||
}
|
||||
// Memory buffer for multipart file parts (bytes beyond this spill to temp files).
|
||||
uploadMem := int64(64 << 20)
|
||||
if s := strings.TrimSpace(os.Getenv("UPLOAD_MULTIPART_MEM_MB")); s != "" {
|
||||
if mb, err := strconv.ParseInt(s, 10, 64); err == nil && mb > 0 {
|
||||
uploadMem = mb * 1 << 20
|
||||
}
|
||||
}
|
||||
s3Bucket := strings.TrimSpace(os.Getenv("S3_BUCKET"))
|
||||
awsRegion := strings.TrimSpace(os.Getenv("AWS_REGION"))
|
||||
if awsRegion == "" {
|
||||
awsRegion = strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION"))
|
||||
}
|
||||
if s3Bucket != "" && awsRegion == "" {
|
||||
awsRegion = "ap-southeast-1"
|
||||
}
|
||||
s3Prefix := strings.TrimSpace(os.Getenv("S3_UPLOAD_PREFIX"))
|
||||
if s3Prefix == "" {
|
||||
s3Prefix = "uploads"
|
||||
}
|
||||
s3Prefix = strings.Trim(s3Prefix, "/")
|
||||
s3Public := strings.TrimSpace(os.Getenv("S3_PUBLIC_BASE_URL"))
|
||||
return Config{
|
||||
Addr: addr,
|
||||
DatabaseURL: db,
|
||||
RunWalletAuthSchema: envBoolDefaultTrue("RUN_WALLET_AUTH_SCHEMA"),
|
||||
JWTSecret: secret,
|
||||
UploadDir: upload,
|
||||
UploadMaxMem: uploadMem,
|
||||
SeedAdmin: os.Getenv("SEED_ADMIN") == "1" || os.Getenv("SEED_ADMIN") == "true",
|
||||
AdminEmail: firstNonEmpty(os.Getenv("ADMIN_EMAIL"), "admin@ark.local"),
|
||||
AdminPass: firstNonEmpty(os.Getenv("ADMIN_PASSWORD"), "admin123"),
|
||||
CORSOrigins: cors,
|
||||
S3Bucket: s3Bucket,
|
||||
AWSRegion: awsRegion,
|
||||
S3UploadPrefix: s3Prefix,
|
||||
S3PublicBase: s3Public,
|
||||
}
|
||||
}
|
||||
|
||||
// envBoolDefaultTrue returns true when the env var is unset; false for 0/false/no/off (case-insensitive).
|
||||
func envBoolDefaultTrue(key string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
if s == "0" || s == "false" || s == "no" || s == "off" {
|
||||
return false
|
||||
}
|
||||
return s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
24
internal/db/db.go
Normal file
24
internal/db/db.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
386
internal/handlers/admin.go
Normal file
386
internal/handlers/admin.go
Normal 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})
|
||||
}
|
||||
40
internal/handlers/context.go
Normal file
40
internal/handlers/context.go
Normal 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
|
||||
}
|
||||
41
internal/handlers/middleware.go
Normal file
41
internal/handlers/middleware.go
Normal 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
451
internal/handlers/public.go
Normal 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()
|
||||
}
|
||||
13
internal/handlers/tagslug.go
Normal file
13
internal/handlers/tagslug.go
Normal 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
115
internal/handlers/upload.go
Normal 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-hosted–style 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
32
internal/handlers/util.go
Normal 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)
|
||||
}
|
||||
168
internal/handlers/wallet_auth.go
Normal file
168
internal/handlers/wallet_auth.go
Normal 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})
|
||||
}
|
||||
}
|
||||
29
internal/seed/seed.go
Normal file
29
internal/seed/seed.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func EnsureAdmin(ctx context.Context, pool *pgxpool.Pool, email, password string) error {
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM admins`).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admins (email, password_hash) VALUES ($1, $2)`, email, string(hash))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("seeded admin user %s", email)
|
||||
return nil
|
||||
}
|
||||
115
migrations/001_init.sql
Normal file
115
migrations/001_init.sql
Normal file
@@ -0,0 +1,115 @@
|
||||
-- ARK 資料庫 initial schema
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE admins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
name_zh_tw TEXT NOT NULL,
|
||||
name_zh_cn TEXT,
|
||||
name_en TEXT,
|
||||
description_zh_tw TEXT,
|
||||
icon_key TEXT DEFAULT 'folder',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE resources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
category_id INT NOT NULL REFERENCES categories(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'zh-TW',
|
||||
cover_image TEXT,
|
||||
file_url TEXT,
|
||||
preview_url TEXT,
|
||||
external_url TEXT,
|
||||
body_text TEXT,
|
||||
badge_label TEXT,
|
||||
is_public BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_downloadable 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,
|
||||
favorite_count INT NOT NULL DEFAULT 0,
|
||||
share_count INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_resources_category ON resources(category_id);
|
||||
CREATE INDEX idx_resources_status_public ON resources(status, is_public);
|
||||
CREATE INDEX idx_resources_published ON resources(published_at DESC NULLS LAST);
|
||||
CREATE INDEX idx_resources_recommended ON resources(is_recommended) WHERE is_recommended = TRUE;
|
||||
|
||||
CREATE TABLE resource_tags (
|
||||
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||
tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (resource_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE search_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
query TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Seed categories (from 文案)
|
||||
INSERT INTO categories (slug, name_zh_tw, name_zh_cn, name_en, description_zh_tw, icon_key, sort_order) VALUES
|
||||
('project-ppt', '項目資料(PPT)', '项目资料(PPT)', 'Project (PPT)', 'ARK 項目介紹、簡報與對外展示資料', 'folder', 1),
|
||||
('daily-class', '每日課堂(空課)', '每日课堂(空课)', 'Daily class', '每日課堂、夜聊、培訓與回放', 'calendar', 2),
|
||||
('official-announcement', '官方公告(推特)', '官方公告(推特)', 'Official news', '官方公告、推文與重要通知', 'megaphone', 3),
|
||||
('academy-materials', '學堂教育(教材)', '学堂教育(教材)', 'Academy materials', '學堂教材、版書與培訓文檔', 'graduation', 4),
|
||||
('global-evangelism', '全球布道(影片)', '全球布道(影片)', 'Global outreach', '布道與活動影片', 'globe', 5),
|
||||
('daily-poster', '每日海報', '每日海报', 'Daily posters', '每日宣傳與活動海報', 'image', 6),
|
||||
('community-tweets', '社群動向(推文)', '社群动向(推文)', 'Community', '社群推文與推廣文案', 'chat', 7),
|
||||
('video-hub', '視頻匯總(影片)', '视频汇总(影片)', 'Video hub', '影片資料匯總', 'film', 8),
|
||||
('subsidy-policy', '補貼政策(活動)', '补贴政策(活动)', 'Campaigns', '補貼、活動規則與激勵方案', 'gift', 9),
|
||||
('how-to', '操作指南(教程)', '操作指南(教程)', 'Tutorials', '操作教學與使用流程', 'book', 10),
|
||||
('official-assets', '官方物料(物料)', '官方物料(物料)', 'Brand assets', 'Logo、模板與品牌素材', 'palette', 11),
|
||||
('media-coverage', '媒體收錄(新聞)', '媒体收录(新闻)', 'Press', '外部媒體與新聞報導', 'newspaper', 12),
|
||||
('academy-video', '學堂教育(影片)', '学堂教育(影片)', 'Academy video', '學堂課程與培訓影片', 'play', 13),
|
||||
('general', 'General', 'General', 'General', '綜合或未分類資料', 'hash', 14);
|
||||
|
||||
INSERT INTO tags (name, slug) VALUES
|
||||
('官方推薦', 'official'),
|
||||
('新人必看', 'newcomer'),
|
||||
('可下載', 'downloadable'),
|
||||
('公告', 'announcement'),
|
||||
('教程', 'tutorial'),
|
||||
('海報', 'poster');
|
||||
|
||||
-- Demo resources (published)
|
||||
INSERT INTO resources (category_id, title, description, type, language, cover_image, file_url, is_public, is_downloadable, is_recommended, status, published_at, badge_label, sort_order)
|
||||
SELECT c.id, 'ARK 項目介紹簡報(示例)', '適合線下宣講與新人培訓。', 'ppt', 'zh-TW', '/uploads/placeholder-cover.svg', '/uploads/placeholder-cover.svg', TRUE, TRUE, TRUE, 'published', NOW() - INTERVAL '2 days', '新人必看', 10
|
||||
FROM categories c WHERE c.slug = 'project-ppt' LIMIT 1;
|
||||
|
||||
INSERT INTO resources (category_id, title, description, type, language, cover_image, preview_url, is_public, is_downloadable, is_recommended, status, published_at, badge_label, sort_order)
|
||||
SELECT c.id, '每日海報 | 示例', '適合每日社群推廣轉發。', 'image', 'zh-TW', '/uploads/placeholder-cover.svg', '/uploads/placeholder-cover.svg', TRUE, TRUE, TRUE, 'published', NOW() - INTERVAL '1 day', '最新公告', 20
|
||||
FROM categories c WHERE c.slug = 'daily-poster' LIMIT 1;
|
||||
|
||||
INSERT INTO resources (category_id, title, description, type, language, external_url, is_public, is_downloadable, is_recommended, status, published_at, sort_order)
|
||||
SELECT c.id, '外部報導連結(示例)', '第三方媒體文章(示例)。', 'link', 'zh-TW', 'https://example.com', TRUE, FALSE, FALSE, 'published', NOW() - INTERVAL '3 hours', 5
|
||||
FROM categories c WHERE c.slug = 'media-coverage' LIMIT 1;
|
||||
|
||||
INSERT INTO resources (category_id, title, description, type, language, cover_image, file_url, is_public, is_downloadable, status, published_at, sort_order)
|
||||
SELECT c.id, '全球布道影片(示例)', '活動精華剪輯。', 'video', 'zh-TW', '/uploads/placeholder-cover.svg', 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', TRUE, TRUE, 'published', NOW() - INTERVAL '5 days', 8
|
||||
FROM categories c WHERE c.slug = 'global-evangelism' LIMIT 1;
|
||||
9
migrations/002_wallet_auth.sql
Normal file
9
migrations/002_wallet_auth.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Wallet sign-in nonces (apply once from a DDL-capable session, e.g. ssh ark-library-backend-admin-1 + psql with admin URL).
|
||||
-- Public API hosts should set RUN_WALLET_AUTH_SCHEMA=false after this exists.
|
||||
|
||||
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);
|
||||
11
static/placeholder-cover.svg
Normal file
11
static/placeholder-cover.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#0a0a0a"/>
|
||||
<stop offset="1" stop-color="#1a1508"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="450" fill="url(#g)"/>
|
||||
<polygon points="400,120 520,360 280,360" fill="none" stroke="#d4af37" stroke-width="6"/>
|
||||
<text x="400" y="400" text-anchor="middle" fill="#c9c9c9" font-family="system-ui,sans-serif" font-size="22">ARK</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 547 B |
Reference in New Issue
Block a user