453 lines
12 KiB
Go
453 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/lib/pq"
|
|
)
|
|
|
|
type App struct {
|
|
db *sql.DB
|
|
auth *Auth
|
|
templates *template.Template
|
|
storageDir string
|
|
}
|
|
|
|
type Skill struct {
|
|
ID int64
|
|
OwnerSub string
|
|
OwnerEmail string
|
|
Title string
|
|
Description string
|
|
PriceCents int
|
|
FilePath string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type UserSkill struct {
|
|
SkillID int64
|
|
Title string
|
|
Description string
|
|
OwnerEmail string
|
|
PurchasedAt time.Time
|
|
}
|
|
|
|
func main() {
|
|
cfg, err := LoadConfig()
|
|
if err != nil {
|
|
log.Fatalf("load config: %v", err)
|
|
}
|
|
|
|
db, err := sql.Open("postgres", cfg.DatabaseURL)
|
|
if err != nil {
|
|
log.Fatalf("open database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := db.Ping(); err != nil {
|
|
log.Fatalf("ping database: %v", err)
|
|
}
|
|
|
|
if err := runMigrations(db); err != nil {
|
|
log.Fatalf("run migrations: %v", err)
|
|
}
|
|
|
|
auth, err := NewAuth(cfg)
|
|
if err != nil {
|
|
log.Fatalf("init auth: %v", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(cfg.StorageDir, 0o755); err != nil {
|
|
log.Fatalf("create storage dir: %v", err)
|
|
}
|
|
|
|
tmpl, err := template.ParseGlob("templates/*.html")
|
|
if err != nil {
|
|
log.Fatalf("parse templates: %v", err)
|
|
}
|
|
|
|
app := &App{
|
|
db: db,
|
|
auth: auth,
|
|
templates: tmpl,
|
|
storageDir: cfg.StorageDir,
|
|
}
|
|
app.auth.WithDB(db)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
|
mux.Handle("/uploads/", app.auth.RequireAuth(http.HandlerFunc(app.handleDownload)))
|
|
mux.HandleFunc("/", app.handleHome)
|
|
mux.Handle("/dashboard", app.auth.RequireAuth(http.HandlerFunc(app.handleDashboard)))
|
|
mux.Handle("/skills", app.auth.RequireAuth(http.HandlerFunc(app.handleSkills)))
|
|
mux.Handle("/purchase", app.auth.RequireAuth(http.HandlerFunc(app.handlePurchase)))
|
|
mux.Handle("/my-skills", app.auth.RequireAuth(http.HandlerFunc(app.handleMySkills)))
|
|
mux.Handle("/api/me", app.auth.RequireAuth(http.HandlerFunc(app.handleMeAPI)))
|
|
mux.Handle("/api/skills", app.auth.RequireAuth(http.HandlerFunc(app.handleSkillsAPI)))
|
|
mux.Handle("/api/upload", app.auth.RequireAuth(http.HandlerFunc(app.handleUploadAPI)))
|
|
mux.Handle("/api/purchase", app.auth.RequireAuth(http.HandlerFunc(app.handlePurchaseAPI)))
|
|
mux.Handle("/api/my-skills", app.auth.RequireAuth(http.HandlerFunc(app.handleMySkillsAPI)))
|
|
|
|
addr := ":" + cfg.Port
|
|
log.Printf("server listening on %s", addr)
|
|
if err := http.ListenAndServe(addr, loggingMiddleware(mux)); err != nil {
|
|
log.Fatalf("server failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func runMigrations(db *sql.DB) error {
|
|
schema, err := os.ReadFile("db/schema.sql")
|
|
if err != nil {
|
|
return fmt.Errorf("read schema: %w", err)
|
|
}
|
|
_, err = db.Exec(string(schema))
|
|
if err != nil {
|
|
return fmt.Errorf("exec schema: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
|
|
})
|
|
}
|
|
|
|
func (a *App) handleHome(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
a.render(w, "home.html", map[string]any{
|
|
"Title": "LLM Skill Marketplace",
|
|
})
|
|
}
|
|
|
|
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
user := MustUserFromContext(r.Context())
|
|
a.render(w, "dashboard.html", map[string]any{
|
|
"Title": "Dashboard",
|
|
"UserEmail": user.Email,
|
|
})
|
|
}
|
|
|
|
func (a *App) handleSkills(w http.ResponseWriter, r *http.Request) {
|
|
a.render(w, "skills.html", map[string]any{
|
|
"Title": "Browse Skills",
|
|
})
|
|
}
|
|
|
|
func (a *App) handleMySkills(w http.ResponseWriter, r *http.Request) {
|
|
a.render(w, "my_skills.html", map[string]any{
|
|
"Title": "My Skills",
|
|
})
|
|
}
|
|
|
|
func (a *App) handlePurchase(w http.ResponseWriter, r *http.Request) {
|
|
a.render(w, "purchase.html", map[string]any{
|
|
"Title": "Purchase Complete",
|
|
})
|
|
}
|
|
|
|
func (a *App) handleMeAPI(w http.ResponseWriter, r *http.Request) {
|
|
user := MustUserFromContext(r.Context())
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"sub": user.Sub,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
})
|
|
}
|
|
|
|
func (a *App) handleSkillsAPI(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
rows, err := a.db.QueryContext(r.Context(), `
|
|
SELECT s.id, s.owner_sub, u.email, s.title, s.description, s.price_cents, s.file_path, s.created_at
|
|
FROM skills s
|
|
JOIN users u ON u.sub = s.owner_sub
|
|
ORDER BY s.created_at DESC
|
|
`)
|
|
if err != nil {
|
|
http.Error(w, "failed to load skills", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var skills []map[string]any
|
|
for rows.Next() {
|
|
var sk Skill
|
|
if err := rows.Scan(&sk.ID, &sk.OwnerSub, &sk.OwnerEmail, &sk.Title, &sk.Description, &sk.PriceCents, &sk.FilePath, &sk.CreatedAt); err != nil {
|
|
http.Error(w, "failed to read skills", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
skills = append(skills, map[string]any{
|
|
"id": sk.ID,
|
|
"title": sk.Title,
|
|
"description": sk.Description,
|
|
"price_cents": sk.PriceCents,
|
|
"owner_email": sk.OwnerEmail,
|
|
"created_at": sk.CreatedAt,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"skills": skills})
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (a *App) handleUploadAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
user := MustUserFromContext(r.Context())
|
|
|
|
if err := r.ParseMultipartForm(20 << 20); err != nil {
|
|
http.Error(w, "invalid form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
title := strings.TrimSpace(r.FormValue("title"))
|
|
description := strings.TrimSpace(r.FormValue("description"))
|
|
priceStr := strings.TrimSpace(r.FormValue("price_cents"))
|
|
if title == "" || description == "" || priceStr == "" {
|
|
http.Error(w, "title, description, and price_cents are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
priceCents, err := strconv.Atoi(priceStr)
|
|
if err != nil || priceCents < 0 {
|
|
http.Error(w, "invalid price_cents", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("skill_file")
|
|
if err != nil {
|
|
http.Error(w, "skill_file is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
safeName := sanitizeFilename(header.Filename)
|
|
if safeName == "" {
|
|
http.Error(w, "invalid filename", http.StatusBadRequest)
|
|
return
|
|
}
|
|
storedName := fmt.Sprintf("%d_%s_%s", time.Now().UnixNano(), user.Sub, safeName)
|
|
fullPath := filepath.Join(a.storageDir, storedName)
|
|
|
|
dst, err := os.Create(fullPath)
|
|
if err != nil {
|
|
http.Error(w, "failed to store file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer dst.Close()
|
|
|
|
if _, err := io.Copy(dst, file); err != nil {
|
|
http.Error(w, "failed to write file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var skillID int64
|
|
err = a.db.QueryRowContext(r.Context(), `
|
|
INSERT INTO skills (owner_sub, title, description, price_cents, file_path)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id
|
|
`, user.Sub, title, description, priceCents, storedName).Scan(&skillID)
|
|
if err != nil {
|
|
http.Error(w, "failed to save skill", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"id": skillID,
|
|
})
|
|
}
|
|
|
|
func (a *App) handlePurchaseAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
user := MustUserFromContext(r.Context())
|
|
skillIDStr := strings.TrimSpace(r.FormValue("skill_id"))
|
|
skillID, err := strconv.ParseInt(skillIDStr, 10, 64)
|
|
if err != nil || skillID <= 0 {
|
|
http.Error(w, "invalid skill_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
tx, err := a.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
http.Error(w, "failed to start purchase", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var ownerSub string
|
|
err = tx.QueryRowContext(ctx, `SELECT owner_sub FROM skills WHERE id = $1`, skillID).Scan(&ownerSub)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "skill not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "failed to load skill", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if ownerSub == user.Sub {
|
|
http.Error(w, "cannot buy your own skill", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO purchases (buyer_sub, skill_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (buyer_sub, skill_id) DO NOTHING
|
|
`, user.Sub, skillID)
|
|
if err != nil {
|
|
http.Error(w, "failed to purchase skill", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
http.Error(w, "failed to complete purchase", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (a *App) handleMySkillsAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
user := MustUserFromContext(r.Context())
|
|
|
|
rows, err := a.db.QueryContext(r.Context(), `
|
|
SELECT s.id, s.title, s.description, u.email, p.purchased_at
|
|
FROM purchases p
|
|
JOIN skills s ON s.id = p.skill_id
|
|
JOIN users u ON u.sub = s.owner_sub
|
|
WHERE p.buyer_sub = $1
|
|
ORDER BY p.purchased_at DESC
|
|
`, user.Sub)
|
|
if err != nil {
|
|
http.Error(w, "failed to load purchased skills", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var skills []map[string]any
|
|
for rows.Next() {
|
|
var s UserSkill
|
|
if err := rows.Scan(&s.SkillID, &s.Title, &s.Description, &s.OwnerEmail, &s.PurchasedAt); err != nil {
|
|
http.Error(w, "failed to read purchased skills", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
skills = append(skills, map[string]any{
|
|
"skill_id": s.SkillID,
|
|
"title": s.Title,
|
|
"description": s.Description,
|
|
"owner_email": s.OwnerEmail,
|
|
"purchased_at": s.PurchasedAt,
|
|
"copy_endpoint": fmt.Sprintf("/uploads/%d", s.SkillID),
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"skills": skills})
|
|
}
|
|
|
|
func (a *App) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
user := MustUserFromContext(r.Context())
|
|
idPart := strings.TrimPrefix(r.URL.Path, "/uploads/")
|
|
skillID, err := strconv.ParseInt(idPart, 10, 64)
|
|
if err != nil || skillID <= 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
var ownerSub, path string
|
|
err = a.db.QueryRowContext(r.Context(), `SELECT owner_sub, file_path FROM skills WHERE id = $1`, skillID).Scan(&ownerSub, &path)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "failed to read file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
allowed := ownerSub == user.Sub
|
|
if !allowed {
|
|
var exists int
|
|
err := a.db.QueryRowContext(r.Context(), `
|
|
SELECT 1 FROM purchases WHERE buyer_sub = $1 AND skill_id = $2
|
|
`, user.Sub, skillID).Scan(&exists)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "purchase required", http.StatusForbidden)
|
|
return
|
|
}
|
|
http.Error(w, "failed authorization check", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
full := filepath.Join(a.storageDir, path)
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\"skill_"+strconv.FormatInt(skillID, 10)+"\"")
|
|
http.ServeFile(w, r, full)
|
|
}
|
|
|
|
func (a *App) render(w http.ResponseWriter, name string, data map[string]any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := a.templates.ExecuteTemplate(w, name, data); err != nil {
|
|
http.Error(w, "template rendering failed", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func sanitizeFilename(name string) string {
|
|
name = filepath.Base(name)
|
|
name = strings.ReplaceAll(name, " ", "_")
|
|
name = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
return r
|
|
case r >= 'A' && r <= 'Z':
|
|
return r
|
|
case r >= '0' && r <= '9':
|
|
return r
|
|
case r == '.', r == '-', r == '_':
|
|
return r
|
|
default:
|
|
return -1
|
|
}
|
|
}, name)
|
|
return name
|
|
}
|