init
This commit is contained in:
commit
4f6d7cfe60
16 changed files with 1341 additions and 0 deletions
453
main.go
Normal file
453
main.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue