387 lines
13 KiB
Go
387 lines
13 KiB
Go
|
|
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})
|
||
|
|
}
|