package main import ( "database/sql" "encoding/json" "errors" "fmt" "html/template" "io" "log" "net" "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 devMode bool } 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, devMode: cfg.DevMode, } 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))) listener, cleanup, err := createListener(cfg) if err != nil { log.Fatalf("create listener: %v", err) } defer cleanup() log.Printf("server listening network=%s address=%s", cfg.ListenNetwork, cfg.ListenAddress) if err := http.Serve(listener, loggingMiddleware(mux)); err != nil { log.Fatalf("server failed: %v", err) } } func createListener(cfg Config) (net.Listener, func(), error) { if cfg.ListenNetwork == "unix" { if err := os.MkdirAll(filepath.Dir(cfg.ListenAddress), 0o755); err != nil { return nil, nil, fmt.Errorf("create socket dir: %w", err) } if _, err := os.Stat(cfg.ListenAddress); err == nil { if err := os.Remove(cfg.ListenAddress); err != nil { return nil, nil, fmt.Errorf("remove stale socket: %w", err) } } else if !os.IsNotExist(err) { return nil, nil, fmt.Errorf("check socket path: %w", err) } l, err := net.Listen("unix", cfg.ListenAddress) if err != nil { return nil, nil, err } if err := os.Chmod(cfg.ListenAddress, cfg.UnixSocketPerm); err != nil { _ = l.Close() return nil, nil, fmt.Errorf("chmod socket: %w", err) } cleanup := func() { _ = l.Close() _ = os.Remove(cfg.ListenAddress) } return l, cleanup, nil } l, err := net.Listen("tcp", cfg.ListenAddress) if err != nil { return nil, nil, err } return l, func() { _ = l.Close() }, nil } 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() lrw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(lrw, r) ip := clientIP(r) log.Printf("method=%s path=%s status=%d bytes=%d duration=%s ip=%s ua=%q", r.Method, r.URL.Path, lrw.status, lrw.bytes, time.Since(start).Round(time.Millisecond), ip, r.UserAgent(), ) }) } // loggingResponseWriter captures status and response size for access logging. type loggingResponseWriter struct { http.ResponseWriter status int bytes int } func (w *loggingResponseWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } func (w *loggingResponseWriter) Write(p []byte) (int, error) { n, err := w.ResponseWriter.Write(p) w.bytes += n return n, err } func clientIP(r *http.Request) string { if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" { parts := strings.Split(xff, ",") if len(parts) > 0 { return strings.TrimSpace(parts[0]) } } if xrip := strings.TrimSpace(r.Header.Get("X-Real-Ip")); xrip != "" { return xrip } host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil && host != "" { return host } return r.RemoteAddr } 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", "DevMode": a.devMode, }) } 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 }