commit 4f6d7cfe6008d84fea837760f2f8c082fc4fdb16 Author: pavel Date: Sun May 10 20:54:14 2026 +0200 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eeccdb9 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +PORT=8080 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/skills_market?sslmode=disable +STORAGE_DIR=uploads +DEV_MODE=true + +OIDC_ISSUER=https://your-oidc-provider.example.com/ +OIDC_AUDIENCE=your-api-audience +OIDC_JWKS_URL=https://your-oidc-provider.example.com/.well-known/jwks.json + +# Optional metadata for frontend OIDC integrations later: +OIDC_AUTH_URL= +OIDC_TOKEN_URL= +OIDC_CLIENT_ID= +OIDC_REDIRECT_URL= +OIDC_LOGOUT_URL= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2174e84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +.DS_Store +bin/ +tmp/ +vendor/ +uploads/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..edf8717 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# LLM Skill Marketplace + +Go + Postgres marketplace where authenticated users can upload LLM skill files, sell them, and buyers can unlock and copy/download purchased skills. + +## Stack +- Backend: Go (`go run .`) +- Frontend: vanilla HTML/CSS/JS +- Database: Postgres +- Auth: external OIDC JWT validation via JWKS +- Config: environment variables with `.env` support + +## Run +1. Copy `.env.example` to `.env` and set values. +2. Start Postgres and create the target database. +3. Install dependencies: + ```bash + go mod tidy + ``` +4. Start server: + ```bash + go run . + ``` +5. Open `http://localhost:8080`. + +## Local dev mode (no auth setup) +Set `DEV_MODE=true` in `.env` (enabled by default in `.env.example`). +In this mode, protected routes always authenticate as: +- `sub`: `demo-user-001` +- `email`: `demo@example.com` +- `name`: `Demo User` + +OIDC variables are not required when `DEV_MODE=true`. + +## Auth flow +API and protected pages require `Authorization: Bearer ` when `DEV_MODE=false`. + +For quick local testing, set in browser console: +```js +localStorage.setItem("access_token", ""); +``` + +## Key routes +- `GET /dashboard`: upload form +- `GET /skills`: browse listings and buy +- `GET /my-skills`: purchased skills and copy/download access +- `POST /api/upload`: upload and list a skill +- `POST /api/purchase`: buy a skill +- `GET /api/my-skills`: owned purchased list +- `GET /uploads/:id`: download unlocked skill file diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..14ad172 --- /dev/null +++ b/auth.go @@ -0,0 +1,267 @@ +package main + +import ( + "context" + "crypto/rsa" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type contextKey string + +const userContextKey contextKey = "auth_user" + +type User struct { + Sub string + Email string + Name string +} + +type Auth struct { + issuer string + audience string + jwksURL string + devMode bool + db *sql.DB + + mu sync.RWMutex + keyByKid map[string]*rsa.PublicKey + lastSyncAt time.Time +} + +func NewAuth(cfg Config) (*Auth, error) { + a := &Auth{ + issuer: cfg.OIDCIssuer, + audience: cfg.OIDCAudience, + jwksURL: cfg.OIDCJWKSURL, + devMode: cfg.DevMode, + keyByKid: map[string]*rsa.PublicKey{}, + lastSyncAt: time.Time{}, + } + if a.devMode { + return a, nil + } + if err := a.refreshKeys(context.Background()); err != nil { + return nil, err + } + return a, nil +} + +func (a *Auth) WithDB(db *sql.DB) { + a.db = db +} + +func (a *Auth) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + user User + err error + ) + if a.devMode { + user = User{ + Sub: "demo-user-001", + Email: "demo@example.com", + Name: "Demo User", + } + } else { + user, err = a.authenticateRequest(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + if a.db != nil { + _, _ = a.db.ExecContext(r.Context(), ` + INSERT INTO users (sub, email, name) + VALUES ($1, $2, $3) + ON CONFLICT (sub) DO UPDATE + SET email = EXCLUDED.email, name = EXCLUDED.name + `, user.Sub, user.Email, user.Name) + } + + ctx := context.WithValue(r.Context(), userContextKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func MustUserFromContext(ctx context.Context) User { + v := ctx.Value(userContextKey) + if v == nil { + return User{} + } + if u, ok := v.(User); ok { + return u + } + return User{} +} + +func (a *Auth) authenticateRequest(r *http.Request) (User, error) { + token := extractBearer(r.Header.Get("Authorization")) + if token == "" { + return User{}, errors.New("missing token") + } + + parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256"})) + parsed, err := parser.Parse(token, func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + if kid == "" { + return nil, errors.New("missing kid") + } + key, err := a.lookupKey(r.Context(), kid) + if err != nil { + return nil, err + } + return key, nil + }) + if err != nil || !parsed.Valid { + return User{}, errors.New("invalid token") + } + + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return User{}, errors.New("invalid claims") + } + + if iss, _ := claims["iss"].(string); iss != a.issuer { + return User{}, errors.New("invalid issuer") + } + if !audienceMatches(claims["aud"], a.audience) { + return User{}, errors.New("invalid audience") + } + + sub, _ := claims["sub"].(string) + email, _ := claims["email"].(string) + name, _ := claims["name"].(string) + if sub == "" { + return User{}, errors.New("missing sub") + } + return User{Sub: sub, Email: email, Name: name}, nil +} + +func (a *Auth) lookupKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + a.mu.RLock() + key := a.keyByKid[kid] + last := a.lastSyncAt + a.mu.RUnlock() + if key != nil { + return key, nil + } + if time.Since(last) > time.Minute { + if err := a.refreshKeys(ctx); err != nil { + return nil, err + } + a.mu.RLock() + defer a.mu.RUnlock() + if refreshed := a.keyByKid[kid]; refreshed != nil { + return refreshed, nil + } + } + return nil, fmt.Errorf("no key for kid %s", kid) +} + +type jwksDoc struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` +} + +func (a *Auth) refreshKeys(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.jwksURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("jwks request failed: %s", resp.Status) + } + + var doc jwksDoc + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return err + } + newKeys := map[string]*rsa.PublicKey{} + for _, k := range doc.Keys { + if k.Kty != "RSA" || k.Kid == "" || k.N == "" || k.E == "" { + continue + } + pub, err := rsaKeyFromJWK(k.N, k.E) + if err != nil { + continue + } + newKeys[k.Kid] = pub + } + if len(newKeys) == 0 { + return errors.New("no usable jwks keys") + } + + a.mu.Lock() + a.keyByKid = newKeys + a.lastSyncAt = time.Now() + a.mu.Unlock() + return nil +} + +func rsaKeyFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(nB64) + if err != nil { + return nil, err + } + eBytes, err := base64.RawURLEncoding.DecodeString(eB64) + if err != nil { + return nil, err + } + n := new(big.Int).SetBytes(nBytes) + e := 0 + for _, b := range eBytes { + e = e<<8 + int(b) + } + if e == 0 { + return nil, errors.New("invalid exponent") + } + return &rsa.PublicKey{N: n, E: e}, nil +} + +func extractBearer(value string) string { + if value == "" { + return "" + } + parts := strings.SplitN(value, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "" + } + return strings.TrimSpace(parts[1]) +} + +func audienceMatches(audValue any, expected string) bool { + switch v := audValue.(type) { + case string: + return v == expected + case []any: + for _, item := range v { + s, ok := item.(string) + if ok && s == expected { + return true + } + } + } + return false +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..4df253e --- /dev/null +++ b/config.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/joho/godotenv" +) + +type Config struct { + Port string + DatabaseURL string + StorageDir string + DevMode bool + OIDCIssuer string + OIDCAudience string + OIDCJWKSURL string + OIDCAuthURL string + OIDCTokenURL string + OIDCClientID string + OIDCRedirect string + OIDCLogoutURL string +} + +func LoadConfig() (Config, error) { + _ = godotenv.Load() + + cfg := Config{ + Port: envOrDefault("PORT", "8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + StorageDir: envOrDefault("STORAGE_DIR", "uploads"), + DevMode: strings.EqualFold(envOrDefault("DEV_MODE", "false"), "true"), + OIDCIssuer: os.Getenv("OIDC_ISSUER"), + OIDCAudience: os.Getenv("OIDC_AUDIENCE"), + OIDCJWKSURL: os.Getenv("OIDC_JWKS_URL"), + OIDCAuthURL: os.Getenv("OIDC_AUTH_URL"), + OIDCTokenURL: os.Getenv("OIDC_TOKEN_URL"), + OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + OIDCRedirect: os.Getenv("OIDC_REDIRECT_URL"), + OIDCLogoutURL: os.Getenv("OIDC_LOGOUT_URL"), + } + + if cfg.DatabaseURL == "" { + return Config{}, fmt.Errorf("DATABASE_URL is required") + } + if !cfg.DevMode && (cfg.OIDCIssuer == "" || cfg.OIDCAudience == "" || cfg.OIDCJWKSURL == "") { + return Config{}, fmt.Errorf("OIDC_ISSUER, OIDC_AUDIENCE, and OIDC_JWKS_URL are required") + } + return cfg, nil +} + +func envOrDefault(key, fallback string) string { + value := os.Getenv(key) + if value == "" { + return fallback + } + return value +} diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..80fc719 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS users ( + sub TEXT PRIMARY KEY, + email TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS skills ( + id BIGSERIAL PRIMARY KEY, + owner_sub TEXT NOT NULL REFERENCES users(sub) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL, + price_cents INTEGER NOT NULL CHECK (price_cents >= 0), + file_path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS purchases ( + id BIGSERIAL PRIMARY KEY, + buyer_sub TEXT NOT NULL REFERENCES users(sub) ON DELETE CASCADE, + skill_id BIGINT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + purchased_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (buyer_sub, skill_id) +); + +CREATE INDEX IF NOT EXISTS idx_skills_owner_sub ON skills(owner_sub); +CREATE INDEX IF NOT EXISTS idx_purchases_buyer_sub ON purchases(buyer_sub); +CREATE INDEX IF NOT EXISTS idx_purchases_skill_id ON purchases(skill_id); + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ef7238f --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module skills2 + +go 1.22.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f0b2cdb --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/main.go b/main.go new file mode 100644 index 0000000..21586da --- /dev/null +++ b/main.go @@ -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 +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..194d6bc --- /dev/null +++ b/static/app.js @@ -0,0 +1,135 @@ +(function () { + function getToken() { + return localStorage.getItem("access_token") || ""; + } + + function authHeaders(extra) { + var headers = extra || {}; + var token = getToken(); + if (token) { + headers.Authorization = "Bearer " + token; + } + return headers; + } + + async function loadSkills() { + var grid = document.getElementById("skill-grid"); + if (!grid) return; + + var res = await fetch("/api/skills", { headers: authHeaders() }); + if (!res.ok) { + grid.innerHTML = '

Set `access_token` in localStorage with a valid OIDC JWT.

'; + return; + } + + var data = await res.json(); + grid.innerHTML = ""; + + data.skills.forEach(function (s) { + var card = document.createElement("article"); + card.className = "card"; + card.innerHTML = + "

" + escapeHtml(s.title) + "

" + + "

" + escapeHtml(s.description) + "

" + + "

Seller: " + escapeHtml(s.owner_email || "unknown") + "

" + + "

$" + (s.price_cents / 100).toFixed(2) + "

" + + ""; + grid.appendChild(card); + }); + + grid.querySelectorAll("[data-skill-id]").forEach(function (btn) { + btn.addEventListener("click", onBuy); + }); + } + + async function onBuy(event) { + var skillId = event.currentTarget.getAttribute("data-skill-id"); + var body = new URLSearchParams(); + body.set("skill_id", skillId); + + var res = await fetch("/api/purchase", { + method: "POST", + headers: authHeaders({ "Content-Type": "application/x-www-form-urlencoded" }), + body: body.toString() + }); + + if (!res.ok) { + alert("Purchase failed."); + return; + } + window.location.href = "/purchase"; + } + + async function loadMySkills() { + var grid = document.getElementById("my-skill-grid"); + if (!grid) return; + + var res = await fetch("/api/my-skills", { headers: authHeaders() }); + if (!res.ok) { + grid.innerHTML = '

Set `access_token` in localStorage with a valid OIDC JWT.

'; + return; + } + + var data = await res.json(); + grid.innerHTML = ""; + + data.skills.forEach(function (s) { + var card = document.createElement("article"); + card.className = "card"; + card.innerHTML = + "

" + escapeHtml(s.title) + "

" + + "

" + escapeHtml(s.description) + "

" + + "

Purchased from: " + escapeHtml(s.owner_email || "unknown") + "

" + + "
" + + "Download" + + "" + + "
"; + grid.appendChild(card); + }); + + grid.querySelectorAll("[data-copy-id]").forEach(function (btn) { + btn.addEventListener("click", function (event) { + var id = event.currentTarget.getAttribute("data-copy-id"); + var link = window.location.origin + "/uploads/" + id; + navigator.clipboard.writeText(link); + event.currentTarget.textContent = "Copied"; + }); + }); + } + + function uploadInit() { + var form = document.getElementById("upload-form"); + if (!form) return; + var status = document.getElementById("upload-status"); + + form.addEventListener("submit", async function (event) { + event.preventDefault(); + var data = new FormData(form); + var res = await fetch("/api/upload", { + method: "POST", + headers: authHeaders(), + body: data + }); + if (!res.ok) { + status.textContent = "Upload failed. Check token and required fields."; + return; + } + status.textContent = "Upload complete. Your skill is live in the marketplace."; + form.reset(); + }); + } + + function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + uploadInit(); + loadSkills(); + loadMySkills(); +})(); + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..f8bac92 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,179 @@ +:root { + --bg: #f4f8f7; + --surface: #ffffff; + --ink: #182226; + --muted: #5a6a71; + --primary: #00796b; + --primary-ink: #ffffff; + --line: #d7e2df; + --accent: #f0b429; + --shadow: 0 18px 35px rgba(0, 47, 42, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Space Grotesk", "Avenir Next", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at 20% 10%, rgba(0, 121, 107, 0.15), transparent 45%), + radial-gradient(circle at 90% 20%, rgba(240, 180, 41, 0.13), transparent 40%), + var(--bg); +} + +.shell { + width: min(1040px, 92vw); + margin: 42px auto 60px; +} + +.hero { + background: linear-gradient(140deg, #ffffff, #ecf7f5); + border: 1px solid var(--line); + border-radius: 26px; + padding: 34px; + box-shadow: var(--shadow); + animation: rise .5s ease both; +} + +.hero.compact { + padding: 24px 28px; + margin-bottom: 22px; +} + +.badge { + display: inline-block; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 10px; + color: var(--muted); + margin: 0 0 10px; +} + +h1 { + margin: 0; + font-size: clamp(1.6rem, 2.2vw, 2.5rem); +} + +h2 { + margin-top: 0; +} + +.lead { + max-width: 70ch; + line-height: 1.5; + color: var(--muted); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +.btn { + appearance: none; + border: 1px solid var(--line); + background: #fff; + color: var(--ink); + padding: 10px 14px; + border-radius: 12px; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +.btn.primary { + background: var(--primary); + border-color: var(--primary); + color: var(--primary-ink); +} + +.btn.buy { + background: var(--accent); + border-color: #d39c20; + color: #332200; +} + +.panel { + margin-top: 22px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 22px; + padding: 24px; + box-shadow: var(--shadow); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.full { + grid-column: 1 / -1; +} + +label { + display: block; + font-size: 0.9rem; + color: var(--muted); +} + +input, +textarea { + width: 100%; + margin-top: 6px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; + font: inherit; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.card { + background: #fff; + border: 1px solid var(--line); + border-radius: 16px; + padding: 16px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.04); + animation: rise .4s ease both; +} + +.card h3 { + margin-top: 0; +} + +.hint { + color: var(--muted); + font-size: 0.9rem; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 760px) { + .form-grid { + grid-template-columns: 1fr; + } +} + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..7c6d263 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,35 @@ + + + + + + {{.Title}} + + + +
+
+

Account

+

Welcome {{.UserEmail}}

+

Manage your listed skills and purchased skills.

+ +
+
+

Upload a skill

+
+ + + + + +
+

+
+
+ + + + diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..5a0a1c4 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,23 @@ + + + + + + {{.Title}} + + + +
+
+

LLM Skill Marketplace

+

Sell, buy, and reuse high quality LLM skills

+

Upload your prompt packages, automations, and reusable skill bundles. Other users can purchase them and copy directly into their own workspace.

+ +

Authentication uses your external OIDC provider token via Authorization header.

+
+
+ + + diff --git a/templates/my_skills.html b/templates/my_skills.html new file mode 100644 index 0000000..4922194 --- /dev/null +++ b/templates/my_skills.html @@ -0,0 +1,26 @@ + + + + + + {{.Title}} + + + +
+
+

Library

+

Your purchased skills

+ +
+
+
+
+
+ + + + diff --git a/templates/purchase.html b/templates/purchase.html new file mode 100644 index 0000000..f4c32da --- /dev/null +++ b/templates/purchase.html @@ -0,0 +1,22 @@ + + + + + + {{.Title}} + + + +
+
+

Purchase

+

Purchase completed

+

Your skill is now saved in your account. You can copy or download it from your purchased skills page.

+ +
+
+ + + diff --git a/templates/skills.html b/templates/skills.html new file mode 100644 index 0000000..c65feb8 --- /dev/null +++ b/templates/skills.html @@ -0,0 +1,26 @@ + + + + + + {{.Title}} + + + +
+
+

Marketplace

+

Browse LLM skills

+ +
+
+
+
+
+ + + +