init
This commit is contained in:
commit
4f6d7cfe60
16 changed files with 1341 additions and 0 deletions
16
.env.example
Normal file
16
.env.example
Normal file
|
|
@ -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=
|
||||||
|
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
bin/
|
||||||
|
tmp/
|
||||||
|
vendor/
|
||||||
|
uploads/
|
||||||
|
|
||||||
49
README.md
Normal file
49
README.md
Normal file
|
|
@ -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 <access_token>` when `DEV_MODE=false`.
|
||||||
|
|
||||||
|
For quick local testing, set in browser console:
|
||||||
|
```js
|
||||||
|
localStorage.setItem("access_token", "<your_oidc_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
|
||||||
267
auth.go
Normal file
267
auth.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
59
config.go
Normal file
59
config.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
29
db/schema.sql
Normal file
29
db/schema.sql
Normal file
|
|
@ -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);
|
||||||
|
|
||||||
9
go.mod
Normal file
9
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
6
go.sum
Normal file
6
go.sum
Normal file
|
|
@ -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=
|
||||||
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
|
||||||
|
}
|
||||||
135
static/app.js
Normal file
135
static/app.js
Normal file
|
|
@ -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 = '<p class="hint">Set `access_token` in localStorage with a valid OIDC JWT.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await res.json();
|
||||||
|
grid.innerHTML = "";
|
||||||
|
|
||||||
|
data.skills.forEach(function (s) {
|
||||||
|
var card = document.createElement("article");
|
||||||
|
card.className = "card";
|
||||||
|
card.innerHTML =
|
||||||
|
"<h3>" + escapeHtml(s.title) + "</h3>" +
|
||||||
|
"<p>" + escapeHtml(s.description) + "</p>" +
|
||||||
|
"<p class='hint'>Seller: " + escapeHtml(s.owner_email || "unknown") + "</p>" +
|
||||||
|
"<p><strong>$" + (s.price_cents / 100).toFixed(2) + "</strong></p>" +
|
||||||
|
"<button class='btn buy' data-skill-id='" + s.id + "'>Buy Skill</button>";
|
||||||
|
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 = '<p class="hint">Set `access_token` in localStorage with a valid OIDC JWT.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await res.json();
|
||||||
|
grid.innerHTML = "";
|
||||||
|
|
||||||
|
data.skills.forEach(function (s) {
|
||||||
|
var card = document.createElement("article");
|
||||||
|
card.className = "card";
|
||||||
|
card.innerHTML =
|
||||||
|
"<h3>" + escapeHtml(s.title) + "</h3>" +
|
||||||
|
"<p>" + escapeHtml(s.description) + "</p>" +
|
||||||
|
"<p class='hint'>Purchased from: " + escapeHtml(s.owner_email || "unknown") + "</p>" +
|
||||||
|
"<div class='actions'>" +
|
||||||
|
"<a class='btn' href='/uploads/" + s.skill_id + "'>Download</a>" +
|
||||||
|
"<button class='btn' data-copy-id='" + s.skill_id + "'>Copy Link</button>" +
|
||||||
|
"</div>";
|
||||||
|
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();
|
||||||
|
})();
|
||||||
|
|
||||||
179
static/styles.css
Normal file
179
static/styles.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
35
templates/dashboard.html
Normal file
35
templates/dashboard.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero compact">
|
||||||
|
<p class="badge">Account</p>
|
||||||
|
<h1>Welcome {{.UserEmail}}</h1>
|
||||||
|
<p class="lead">Manage your listed skills and purchased skills.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn primary" href="/skills">Browse Skills</a>
|
||||||
|
<a class="btn" href="/my-skills">My Purchases</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Upload a skill</h2>
|
||||||
|
<form id="upload-form" class="form-grid">
|
||||||
|
<label>Title<input name="title" required></label>
|
||||||
|
<label>Price (cents)<input name="price_cents" type="number" min="0" required></label>
|
||||||
|
<label class="full">Description<textarea name="description" rows="4" required></textarea></label>
|
||||||
|
<label class="full">Skill file<input name="skill_file" type="file" required></label>
|
||||||
|
<button class="btn primary" type="submit">Publish Skill</button>
|
||||||
|
</form>
|
||||||
|
<p id="upload-status" class="hint"></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
23
templates/home.html
Normal file
23
templates/home.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<p class="badge">LLM Skill Marketplace</p>
|
||||||
|
<h1>Sell, buy, and reuse high quality LLM skills</h1>
|
||||||
|
<p class="lead">Upload your prompt packages, automations, and reusable skill bundles. Other users can purchase them and copy directly into their own workspace.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn primary" href="/dashboard">Open Dashboard</a>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Authentication uses your external OIDC provider token via Authorization header.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
26
templates/my_skills.html
Normal file
26
templates/my_skills.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero compact">
|
||||||
|
<p class="badge">Library</p>
|
||||||
|
<h1>Your purchased skills</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn" href="/dashboard">Back to Dashboard</a>
|
||||||
|
<a class="btn" href="/skills">Browse More Skills</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div id="my-skill-grid" class="card-grid"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
22
templates/purchase.html
Normal file
22
templates/purchase.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero compact">
|
||||||
|
<p class="badge">Purchase</p>
|
||||||
|
<h1>Purchase completed</h1>
|
||||||
|
<p class="lead">Your skill is now saved in your account. You can copy or download it from your purchased skills page.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn primary" href="/my-skills">Open My Purchases</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
26
templates/skills.html
Normal file
26
templates/skills.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero compact">
|
||||||
|
<p class="badge">Marketplace</p>
|
||||||
|
<h1>Browse LLM skills</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn" href="/dashboard">Back to Dashboard</a>
|
||||||
|
<a class="btn" href="/my-skills">My Purchases</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div id="skill-grid" class="card-grid"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue