This commit is contained in:
pavel 2026-05-10 20:54:14 +02:00
commit 4f6d7cfe60
16 changed files with 1341 additions and 0 deletions

453
main.go Normal file
View file

@ -0,0 +1,453 @@
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
}