From 141d92dc158525399846d2a4f19ce9b0a5a93fc3 Mon Sep 17 00:00:00 2001 From: TerryM Date: Sat, 16 May 2026 00:18:22 +0800 Subject: [PATCH] Initial backend import --- .dockerignore | 3 + .gitignore | 25 ++ Dockerfile | 17 ++ cmd/server/main.go | 171 ++++++++++++ go.mod | 48 ++++ go.sum | 124 +++++++++ internal/auth/jwt.go | 45 +++ internal/auth/jwt_user.go | 49 ++++ internal/config/config.go | 112 ++++++++ internal/db/db.go | 24 ++ internal/handlers/admin.go | 386 ++++++++++++++++++++++++++ internal/handlers/context.go | 40 +++ internal/handlers/middleware.go | 41 +++ internal/handlers/public.go | 451 +++++++++++++++++++++++++++++++ internal/handlers/tagslug.go | 13 + internal/handlers/upload.go | 115 ++++++++ internal/handlers/util.go | 32 +++ internal/handlers/wallet_auth.go | 168 ++++++++++++ internal/seed/seed.go | 29 ++ migrations/001_init.sql | 115 ++++++++ migrations/002_wallet_auth.sql | 9 + static/placeholder-cover.svg | 11 + 22 files changed, 2028 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/jwt_user.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/handlers/admin.go create mode 100644 internal/handlers/context.go create mode 100644 internal/handlers/middleware.go create mode 100644 internal/handlers/public.go create mode 100644 internal/handlers/tagslug.go create mode 100644 internal/handlers/upload.go create mode 100644 internal/handlers/util.go create mode 100644 internal/handlers/wallet_auth.go create mode 100644 internal/seed/seed.go create mode 100644 migrations/001_init.sql create mode 100644 migrations/002_wallet_auth.sql create mode 100644 static/placeholder-cover.svg diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5dce865 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +**/uploads +**/.git +/tmp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a0cc76 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..80dd6f3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ebb5bc9 --- /dev/null +++ b/cmd/server/main.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fef0b83 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2b935af --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..0461608 --- /dev/null +++ b/internal/auth/jwt.go @@ -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 +} diff --git a/internal/auth/jwt_user.go b/internal/auth/jwt_user.go new file mode 100644 index 0000000..1ec233a --- /dev/null +++ b/internal/auth/jwt_user.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9a9792e --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..656d13a --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go new file mode 100644 index 0000000..4b1b2e9 --- /dev/null +++ b/internal/handlers/admin.go @@ -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}) +} diff --git a/internal/handlers/context.go b/internal/handlers/context.go new file mode 100644 index 0000000..cf3da1e --- /dev/null +++ b/internal/handlers/context.go @@ -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 +} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go new file mode 100644 index 0000000..6d9ad93 --- /dev/null +++ b/internal/handlers/middleware.go @@ -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) +} diff --git a/internal/handlers/public.go b/internal/handlers/public.go new file mode 100644 index 0000000..f85fb75 --- /dev/null +++ b/internal/handlers/public.go @@ -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() +} diff --git a/internal/handlers/tagslug.go b/internal/handlers/tagslug.go new file mode 100644 index 0000000..ad011fb --- /dev/null +++ b/internal/handlers/tagslug.go @@ -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()) +} diff --git a/internal/handlers/upload.go b/internal/handlers/upload.go new file mode 100644 index 0000000..a748071 --- /dev/null +++ b/internal/handlers/upload.go @@ -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) +} diff --git a/internal/handlers/util.go b/internal/handlers/util.go new file mode 100644 index 0000000..98000c4 --- /dev/null +++ b/internal/handlers/util.go @@ -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) +} diff --git a/internal/handlers/wallet_auth.go b/internal/handlers/wallet_auth.go new file mode 100644 index 0000000..aa040f0 --- /dev/null +++ b/internal/handlers/wallet_auth.go @@ -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}) + } +} diff --git a/internal/seed/seed.go b/internal/seed/seed.go new file mode 100644 index 0000000..240ce3b --- /dev/null +++ b/internal/seed/seed.go @@ -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 +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..307993b --- /dev/null +++ b/migrations/001_init.sql @@ -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; diff --git a/migrations/002_wallet_auth.sql b/migrations/002_wallet_auth.sql new file mode 100644 index 0000000..48ff778 --- /dev/null +++ b/migrations/002_wallet_auth.sql @@ -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); diff --git a/static/placeholder-cover.svg b/static/placeholder-cover.svg new file mode 100644 index 0000000..d9ed107 --- /dev/null +++ b/static/placeholder-cover.svg @@ -0,0 +1,11 @@ + + + + + + + + + + ARK +