This commit is contained in:
Josh 2026-01-09 00:16:46 +02:00
commit d69342b2e9
160 changed files with 28681 additions and 0 deletions

View file

@ -0,0 +1,54 @@
package auth
import (
"context"
"net/http"
"strings"
"github.com/writekitapp/writekit/internal/db"
)
type ctxKey string
const userIDKey ctxKey = "userID"
func SessionMiddleware(database *db.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
session, err := database.ValidateSession(r.Context(), token)
if err != nil || session == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userIDKey, session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUserID(r *http.Request) string {
if id, ok := r.Context().Value(userIDKey).(string); ok {
return id
}
return ""
}
func extractToken(r *http.Request) string {
if cookie, err := r.Cookie("writekit_session"); err == nil && cookie.Value != "" {
return cookie.Value
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return r.URL.Query().Get("token")
}

535
internal/auth/oauth.go Normal file
View file

@ -0,0 +1,535 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/writekitapp/writekit/internal/db"
)
type Handler struct {
database *db.DB
sessionSecret []byte
baseURL string
providers map[string]provider
}
type provider struct {
Name string
ClientID string
ClientSecret string
AuthURL string
TokenURL string
UserInfoURL string
Scopes []string
}
type oauthToken struct {
AccessToken string `json:"access_token"`
}
type oauthState struct {
Provider string `json:"p"`
TenantID string `json:"t,omitempty"`
Redirect string `json:"r,omitempty"`
Callback string `json:"c,omitempty"`
Timestamp int64 `json:"ts"`
}
type userInfo struct {
ID string
Email string
Name string
AvatarURL string
}
func NewHandler(database *db.DB) *Handler {
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
secret := os.Getenv("SESSION_SECRET")
if secret == "" {
secret = "dev-secret-change-in-production"
}
h := &Handler{
database: database,
sessionSecret: []byte(secret),
baseURL: baseURL,
providers: make(map[string]provider),
}
if id := os.Getenv("GOOGLE_CLIENT_ID"); id != "" {
h.providers["google"] = provider{
Name: "Google",
ClientID: id,
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
Scopes: []string{"email", "profile"},
}
}
if id := os.Getenv("GITHUB_CLIENT_ID"); id != "" {
h.providers["github"] = provider{
Name: "GitHub",
ClientID: id,
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
UserInfoURL: "https://api.github.com/user",
Scopes: []string{"user:email"},
}
}
if id := os.Getenv("DISCORD_CLIENT_ID"); id != "" {
h.providers["discord"] = provider{
Name: "Discord",
ClientID: id,
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
AuthURL: "https://discord.com/api/oauth2/authorize",
TokenURL: "https://discord.com/api/oauth2/token",
UserInfoURL: "https://discord.com/api/users/@me",
Scopes: []string{"identify", "email"},
}
}
return h
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/google", h.initiate)
r.Get("/github", h.initiate)
r.Get("/discord", h.initiate)
r.Get("/callback", h.callback)
r.Get("/validate", h.validate)
r.Get("/user", h.user)
r.Get("/providers", h.listProviders)
r.Post("/logout", h.logout)
return r
}
func (h *Handler) initiate(w http.ResponseWriter, r *http.Request) {
providerName := strings.TrimPrefix(r.URL.Path, "/auth/")
if _, ok := h.providers[providerName]; !ok {
http.Error(w, "unknown provider", http.StatusBadRequest)
return
}
p := h.providers[providerName]
state := oauthState{
Provider: providerName,
TenantID: r.URL.Query().Get("tenant"),
Redirect: r.URL.Query().Get("redirect"),
Callback: r.URL.Query().Get("callback"),
Timestamp: time.Now().Unix(),
}
stateStr, err := h.encodeState(state)
if err != nil {
slog.Error("encode state", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
authURL := fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s",
p.AuthURL,
url.QueryEscape(p.ClientID),
url.QueryEscape(h.baseURL+"/auth/callback"),
url.QueryEscape(strings.Join(p.Scopes, " ")),
url.QueryEscape(stateStr),
)
if providerName == "discord" {
authURL += "&prompt=consent"
}
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}
func (h *Handler) callback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
stateStr := r.URL.Query().Get("state")
if code == "" || stateStr == "" {
http.Error(w, "missing code or state", http.StatusBadRequest)
return
}
state, err := h.decodeState(stateStr)
if err != nil {
slog.Error("decode state", "error", err)
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
if time.Now().Unix()-state.Timestamp > 600 {
http.Error(w, "state expired", http.StatusBadRequest)
return
}
p, ok := h.providers[state.Provider]
if !ok {
http.Error(w, "unknown provider", http.StatusBadRequest)
return
}
token, err := h.exchangeCode(r.Context(), p, code)
if err != nil {
slog.Error("exchange code", "error", err)
http.Error(w, "auth failed", http.StatusInternalServerError)
return
}
info, err := h.getUserInfo(r.Context(), p, state.Provider, token)
if err != nil {
slog.Error("get user info", "error", err)
http.Error(w, "failed to get user info", http.StatusInternalServerError)
return
}
user, err := h.findOrCreateUser(r.Context(), state.Provider, info)
if err != nil {
slog.Error("find or create user", "error", err)
http.Error(w, "failed to create user", http.StatusInternalServerError)
return
}
session, err := h.database.CreateSession(r.Context(), user.ID)
if err != nil {
slog.Error("create session", "error", err)
http.Error(w, "failed to create session", http.StatusInternalServerError)
return
}
if state.Callback != "" {
callbackURL := state.Callback
if strings.Contains(callbackURL, "?") {
callbackURL += "&token=" + session.Token
} else {
callbackURL += "?token=" + session.Token
}
http.Redirect(w, r, callbackURL, http.StatusTemporaryRedirect)
return
}
redirect := state.Redirect
if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
redirect = "/"
}
http.SetCookie(w, &http.Cookie{
Name: "writekit_session",
Value: session.Token,
Path: "/",
Expires: session.ExpiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !strings.Contains(h.baseURL, "localhost"),
})
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
}
func (h *Handler) validate(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", http.StatusBadRequest)
return
}
session, err := h.database.ValidateSession(r.Context(), token)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if session == nil {
http.Error(w, "invalid session", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *Handler) user(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", http.StatusBadRequest)
return
}
session, err := h.database.ValidateSession(r.Context(), token)
if err != nil || session == nil {
http.Error(w, "invalid session", http.StatusUnauthorized)
return
}
user, err := h.database.GetUserByID(r.Context(), session.UserID)
if err != nil || user == nil {
http.Error(w, "user not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"avatar_url": user.AvatarURL,
})
}
func (h *Handler) listProviders(w http.ResponseWriter, r *http.Request) {
providers := []map[string]string{}
if _, ok := h.providers["google"]; ok {
providers = append(providers, map[string]string{"id": "google", "name": "Google"})
}
if _, ok := h.providers["github"]; ok {
providers = append(providers, map[string]string{"id": "github", "name": "GitHub"})
}
if _, ok := h.providers["discord"]; ok {
providers = append(providers, map[string]string{"id": "discord", "name": "Discord"})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"providers": providers})
}
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("writekit_session")
if err == nil && cookie.Value != "" {
h.database.DeleteSession(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "writekit_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: !strings.Contains(h.baseURL, "localhost"),
})
w.WriteHeader(http.StatusOK)
}
func (h *Handler) exchangeCode(ctx context.Context, p provider, code string) (*oauthToken, error) {
data := url.Values{}
data.Set("client_id", p.ClientID)
data.Set("client_secret", p.ClientSecret)
data.Set("code", code)
data.Set("redirect_uri", h.baseURL+"/auth/callback")
data.Set("grant_type", "authorization_code")
req, err := http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange failed: %s", body)
}
var token oauthToken
if err := json.Unmarshal(body, &token); err != nil {
return nil, err
}
return &token, nil
}
func (h *Handler) getUserInfo(ctx context.Context, p provider, providerName string, token *oauthToken) (*userInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", p.UserInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("user info failed: %s", body)
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
info := &userInfo{}
switch providerName {
case "google":
info.ID = getString(raw, "id")
info.Email = getString(raw, "email")
info.Name = getString(raw, "name")
info.AvatarURL = getString(raw, "picture")
case "github":
info.ID = fmt.Sprintf("%v", raw["id"])
info.Email = getString(raw, "email")
info.Name = getString(raw, "name")
if info.Name == "" {
info.Name = getString(raw, "login")
}
info.AvatarURL = getString(raw, "avatar_url")
if info.Email == "" {
info.Email, _ = h.getGitHubEmail(ctx, token)
}
case "discord":
info.ID = getString(raw, "id")
info.Email = getString(raw, "email")
info.Name = getString(raw, "global_name")
if info.Name == "" {
info.Name = getString(raw, "username")
}
if avatar := getString(raw, "avatar"); avatar != "" {
info.AvatarURL = fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", info.ID, avatar)
}
}
if info.Email == "" {
return nil, fmt.Errorf("no email from provider")
}
return info, nil
}
func (h *Handler) getGitHubEmail(ctx context.Context, token *oauthToken) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/emails", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil {
return "", err
}
for _, e := range emails {
if e.Primary && e.Verified {
return e.Email, nil
}
}
for _, e := range emails {
if e.Verified {
return e.Email, nil
}
}
return "", nil
}
func (h *Handler) findOrCreateUser(ctx context.Context, providerName string, info *userInfo) (*db.User, error) {
user, err := h.database.GetUserByIdentity(ctx, providerName, info.ID)
if err != nil {
return nil, err
}
if user != nil {
return user, nil
}
user, err = h.database.GetUserByEmail(ctx, info.Email)
if err != nil {
return nil, err
}
if user != nil {
h.database.AddUserIdentity(ctx, user.ID, providerName, info.ID, info.Email)
return user, nil
}
user, err = h.database.CreateUser(ctx, info.Email, info.Name, info.AvatarURL)
if err != nil {
return nil, err
}
h.database.AddUserIdentity(ctx, user.ID, providerName, info.ID, info.Email)
return user, nil
}
func (h *Handler) encodeState(state oauthState) (string, error) {
data, err := json.Marshal(state)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, h.sessionSecret)
mac.Write(data)
sig := mac.Sum(nil)
return base64.URLEncoding.EncodeToString(append(data, sig...)), nil
}
func (h *Handler) decodeState(s string) (*oauthState, error) {
payload, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return nil, err
}
if len(payload) < 32 {
return nil, fmt.Errorf("invalid state")
}
data := payload[:len(payload)-32]
sig := payload[len(payload)-32:]
mac := hmac.New(sha256.New, h.sessionSecret)
mac.Write(data)
if !hmac.Equal(sig, mac.Sum(nil)) {
return nil, fmt.Errorf("invalid signature")
}
var state oauthState
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
func getString(m map[string]any, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}

234
internal/billing/lemon.go Normal file
View file

@ -0,0 +1,234 @@
package billing
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const lemonBaseURL = "https://api.lemonsqueezy.com/v1"
type LemonClient struct {
apiKey string
storeID string
webhookSecret string
httpClient *http.Client
}
func NewLemonClient() *LemonClient {
return &LemonClient{
apiKey: os.Getenv("LEMON_API_KEY"),
storeID: os.Getenv("LEMON_STORE_ID"),
webhookSecret: os.Getenv("LEMON_WEBHOOK_SECRET"),
httpClient: &http.Client{},
}
}
func (c *LemonClient) IsConfigured() bool {
return c.apiKey != "" && c.storeID != ""
}
type CheckoutRequest struct {
StoreID string `json:"store_id"`
VariantID string `json:"variant_id"`
CustomPrice *int `json:"custom_price,omitempty"`
CheckoutOptions *CheckoutOptions `json:"checkout_options,omitempty"`
CheckoutData *CheckoutData `json:"checkout_data,omitempty"`
}
type CheckoutOptions struct {
Embed bool `json:"embed,omitempty"`
}
type CheckoutData struct {
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Custom map[string]string `json:"custom,omitempty"`
}
type CheckoutResponse struct {
Data struct {
ID string `json:"id"`
Attributes struct {
URL string `json:"url"`
} `json:"attributes"`
} `json:"data"`
}
func (c *LemonClient) CreateCheckout(ctx context.Context, req *CheckoutRequest) (*CheckoutResponse, error) {
if req.StoreID == "" {
req.StoreID = c.storeID
}
payload := map[string]any{
"data": map[string]any{
"type": "checkouts",
"attributes": req,
"relationships": map[string]any{
"store": map[string]any{
"data": map[string]any{"type": "stores", "id": req.StoreID},
},
"variant": map[string]any{
"data": map[string]any{"type": "variants", "id": req.VariantID},
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", lemonBaseURL+"/checkouts", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Content-Type", "application/vnd.api+json")
httpReq.Header.Set("Accept", "application/vnd.api+json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("checkout creation failed (status %d): %s", resp.StatusCode, string(respBody))
}
var checkoutResp CheckoutResponse
if err := json.Unmarshal(respBody, &checkoutResp); err != nil {
return nil, err
}
return &checkoutResp, nil
}
func (c *LemonClient) CreateSubscriptionCheckout(ctx context.Context, variantID, email, name string, custom map[string]string) (string, error) {
req := &CheckoutRequest{
VariantID: variantID,
CheckoutData: &CheckoutData{
Email: email,
Name: name,
Custom: custom,
},
CheckoutOptions: &CheckoutOptions{Embed: true},
}
resp, err := c.CreateCheckout(ctx, req)
if err != nil {
return "", err
}
return resp.Data.Attributes.URL, nil
}
func (c *LemonClient) CreateDonationCheckout(ctx context.Context, variantID string, amountCents int, email, name, message string, custom map[string]string) (string, error) {
if custom == nil {
custom = make(map[string]string)
}
if message != "" {
custom["message"] = message
}
req := &CheckoutRequest{
VariantID: variantID,
CustomPrice: &amountCents,
CheckoutData: &CheckoutData{
Email: email,
Name: name,
Custom: custom,
},
CheckoutOptions: &CheckoutOptions{Embed: true},
}
resp, err := c.CreateCheckout(ctx, req)
if err != nil {
return "", err
}
return resp.Data.Attributes.URL, nil
}
func (c *LemonClient) VerifyWebhook(payload []byte, signature string) bool {
if c.webhookSecret == "" {
return false
}
mac := hmac.New(sha256.New, []byte(c.webhookSecret))
mac.Write(payload)
expectedSig := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expectedSig))
}
type WebhookEvent struct {
Meta struct {
EventName string `json:"event_name"`
CustomData map[string]string `json:"custom_data"`
} `json:"meta"`
Data json.RawMessage `json:"data"`
}
type SubscriptionData struct {
ID string `json:"id"`
Attributes struct {
CustomerID int `json:"customer_id"`
VariantName string `json:"variant_name"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Status string `json:"status"`
RenewsAt string `json:"renews_at"`
FirstSubscriptionItem struct {
Price int `json:"price"`
} `json:"first_subscription_item"`
} `json:"attributes"`
}
type OrderData struct {
ID string `json:"id"`
Attributes struct {
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
TotalUsd int `json:"total_usd"`
} `json:"attributes"`
}
func (c *LemonClient) ParseWebhookEvent(payload []byte) (*WebhookEvent, error) {
var event WebhookEvent
if err := json.Unmarshal(payload, &event); err != nil {
return nil, err
}
return &event, nil
}
func (e *WebhookEvent) GetSubscriptionData() (*SubscriptionData, error) {
var data SubscriptionData
if err := json.Unmarshal(e.Data, &data); err != nil {
return nil, err
}
return &data, nil
}
func (e *WebhookEvent) GetOrderData() (*OrderData, error) {
var data OrderData
if err := json.Unmarshal(e.Data, &data); err != nil {
return nil, err
}
return &data, nil
}

126
internal/billing/payout.go Normal file
View file

@ -0,0 +1,126 @@
package billing
import (
"context"
"fmt"
"log/slog"
"os"
"strconv"
"time"
)
type PayoutWorker struct {
store *Store
wise *WiseClient
thresholdCents int
interval time.Duration
}
func NewPayoutWorker(store *Store, wiseClient *WiseClient) *PayoutWorker {
threshold := 1000
if v := os.Getenv("PAYOUT_THRESHOLD_CENTS"); v != "" {
if t, err := strconv.Atoi(v); err == nil {
threshold = t
}
}
return &PayoutWorker{
store: store,
wise: wiseClient,
thresholdCents: threshold,
interval: 5 * time.Minute,
}
}
func (w *PayoutWorker) Start(ctx context.Context) {
if !w.wise.IsConfigured() {
slog.Warn("wise not configured, payouts disabled")
return
}
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
slog.Info("payout worker started", "threshold_cents", w.thresholdCents, "interval", w.interval)
for {
select {
case <-ctx.Done():
slog.Info("payout worker stopped")
return
case <-ticker.C:
w.processPayouts(ctx)
}
}
}
func (w *PayoutWorker) processPayouts(ctx context.Context) {
balances, err := w.store.GetTenantsReadyForPayout(ctx, w.thresholdCents)
if err != nil {
slog.Error("failed to get tenants ready for payout", "error", err)
return
}
for _, balance := range balances {
if err := w.processPayout(ctx, balance.TenantID, balance.AvailableCents); err != nil {
slog.Error("failed to process payout",
"tenant_id", balance.TenantID,
"amount_cents", balance.AvailableCents,
"error", err,
)
continue
}
}
}
func (w *PayoutWorker) processPayout(ctx context.Context, tenantID string, amountCents int) error {
settings, err := w.store.GetPayoutSettings(ctx, tenantID)
if err != nil {
return fmt.Errorf("get payout settings: %w", err)
}
if settings == nil || settings.WiseRecipientID == "" {
return fmt.Errorf("payout not configured for tenant")
}
recipientID, err := strconv.ParseInt(settings.WiseRecipientID, 10, 64)
if err != nil {
return fmt.Errorf("invalid recipient ID: %w", err)
}
quote, err := w.wise.CreateQuote(ctx, "USD", settings.Currency, amountCents)
if err != nil {
return fmt.Errorf("create quote: %w", err)
}
reference := fmt.Sprintf("WK-%s-%d", tenantID[:8], time.Now().Unix())
transfer, err := w.wise.CreateTransfer(ctx, quote.ID, recipientID, reference)
if err != nil {
return fmt.Errorf("create transfer: %w", err)
}
payoutID, err := w.store.CreatePayout(ctx, tenantID, amountCents, settings.Currency, fmt.Sprintf("%d", transfer.ID), quote.ID, "pending")
if err != nil {
return fmt.Errorf("record payout: %w", err)
}
if err := w.wise.FundTransfer(ctx, transfer.ID); err != nil {
w.store.UpdatePayoutStatus(ctx, payoutID, "failed", nil, err.Error())
return fmt.Errorf("fund transfer: %w", err)
}
if err := w.store.DeductBalance(ctx, tenantID, amountCents); err != nil {
return fmt.Errorf("deduct balance: %w", err)
}
w.store.UpdatePayoutStatus(ctx, payoutID, "processing", nil, "")
slog.Info("payout initiated",
"tenant_id", tenantID,
"amount_cents", amountCents,
"transfer_id", transfer.ID,
"payout_id", payoutID,
)
return nil
}

333
internal/billing/store.go Normal file
View file

@ -0,0 +1,333 @@
package billing
import (
"context"
"database/sql"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
type Tier struct {
ID string
TenantID string
Name string
PriceCents int
Description string
LemonVariantID string
Active bool
CreatedAt time.Time
}
type Subscription struct {
ID string
TenantID string
UserID string
TierID string
TierName string
Status string
LemonSubscriptionID string
LemonCustomerID string
AmountCents int
CurrentPeriodStart time.Time
CurrentPeriodEnd time.Time
CancelledAt *time.Time
CreatedAt time.Time
}
type Donation struct {
ID string
TenantID string
UserID string
DonorEmail string
DonorName string
AmountCents int
LemonOrderID string
Message string
CreatedAt time.Time
}
type Balance struct {
TenantID string
AvailableCents int
LifetimeEarningsCents int
LifetimePaidCents int
UpdatedAt time.Time
}
type PayoutSettings struct {
TenantID string
WiseRecipientID string
AccountHolderName string
Currency string
PayoutEmail string
}
func (s *Store) CreateTier(ctx context.Context, tier *Tier) error {
_, err := s.db.Exec(ctx, `
INSERT INTO membership_tiers (tenant_id, name, price_cents, description, lemon_variant_id, active)
VALUES ($1, $2, $3, $4, $5, $6)
`, tier.TenantID, tier.Name, tier.PriceCents, tier.Description, tier.LemonVariantID, tier.Active)
return err
}
func (s *Store) GetTiersByTenant(ctx context.Context, tenantID string) ([]Tier, error) {
rows, err := s.db.Query(ctx, `
SELECT id, tenant_id, name, price_cents, description, lemon_variant_id, active, created_at
FROM membership_tiers
WHERE tenant_id = $1 AND active = TRUE
ORDER BY price_cents ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var tiers []Tier
for rows.Next() {
var t Tier
if err := rows.Scan(&t.ID, &t.TenantID, &t.Name, &t.PriceCents, &t.Description, &t.LemonVariantID, &t.Active, &t.CreatedAt); err != nil {
return nil, err
}
tiers = append(tiers, t)
}
return tiers, rows.Err()
}
func (s *Store) CreateSubscription(ctx context.Context, sub *Subscription) error {
_, err := s.db.Exec(ctx, `
INSERT INTO subscriptions (tenant_id, user_id, tier_id, tier_name, status, lemon_subscription_id, lemon_customer_id, amount_cents, current_period_start, current_period_end)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, sub.TenantID, sub.UserID, sub.TierID, sub.TierName, sub.Status, sub.LemonSubscriptionID, sub.LemonCustomerID, sub.AmountCents, sub.CurrentPeriodStart, sub.CurrentPeriodEnd)
return err
}
func (s *Store) GetSubscriptionByLemonID(ctx context.Context, lemonSubID string) (*Subscription, error) {
var sub Subscription
var userID, tierID sql.NullString
var cancelledAt sql.NullTime
var periodStart, periodEnd sql.NullTime
err := s.db.QueryRow(ctx, `
SELECT id, tenant_id, user_id, tier_id, tier_name, status, lemon_subscription_id, lemon_customer_id,
amount_cents, current_period_start, current_period_end, cancelled_at, created_at
FROM subscriptions WHERE lemon_subscription_id = $1
`, lemonSubID).Scan(&sub.ID, &sub.TenantID, &userID, &tierID, &sub.TierName, &sub.Status,
&sub.LemonSubscriptionID, &sub.LemonCustomerID, &sub.AmountCents, &periodStart, &periodEnd,
&cancelledAt, &sub.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
sub.UserID = userID.String
sub.TierID = tierID.String
if periodStart.Valid {
sub.CurrentPeriodStart = periodStart.Time
}
if periodEnd.Valid {
sub.CurrentPeriodEnd = periodEnd.Time
}
if cancelledAt.Valid {
sub.CancelledAt = &cancelledAt.Time
}
return &sub, nil
}
func (s *Store) UpdateSubscriptionStatus(ctx context.Context, lemonSubID, status string, renewsAt *time.Time) error {
_, err := s.db.Exec(ctx, `
UPDATE subscriptions
SET status = $1, current_period_end = $2, updated_at = NOW()
WHERE lemon_subscription_id = $3
`, status, renewsAt, lemonSubID)
return err
}
func (s *Store) CancelSubscription(ctx context.Context, lemonSubID string) error {
_, err := s.db.Exec(ctx, `
UPDATE subscriptions
SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW()
WHERE lemon_subscription_id = $1
`, lemonSubID)
return err
}
func (s *Store) CreateDonation(ctx context.Context, donation *Donation) error {
err := s.db.QueryRow(ctx, `
INSERT INTO donations (tenant_id, user_id, donor_email, donor_name, amount_cents, lemon_order_id, message)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, donation.TenantID, donation.UserID, donation.DonorEmail, donation.DonorName, donation.AmountCents, donation.LemonOrderID, donation.Message).Scan(&donation.ID)
return err
}
func (s *Store) AddEarnings(ctx context.Context, tenantID, sourceType, sourceID, description string, grossCents, platformFeeCents, processorFeeCents, netCents int) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO earnings (tenant_id, source_type, source_id, description, gross_cents, platform_fee_cents, processor_fee_cents, net_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, tenantID, sourceType, sourceID, description, grossCents, platformFeeCents, processorFeeCents, netCents)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO balances (tenant_id, available_cents, lifetime_earnings_cents, updated_at)
VALUES ($1, $2, $2, NOW())
ON CONFLICT (tenant_id) DO UPDATE
SET available_cents = balances.available_cents + $2,
lifetime_earnings_cents = balances.lifetime_earnings_cents + $2,
updated_at = NOW()
`, tenantID, netCents)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) GetBalance(ctx context.Context, tenantID string) (*Balance, error) {
var b Balance
err := s.db.QueryRow(ctx, `
SELECT tenant_id, available_cents, lifetime_earnings_cents, lifetime_paid_cents, updated_at
FROM balances WHERE tenant_id = $1
`, tenantID).Scan(&b.TenantID, &b.AvailableCents, &b.LifetimeEarningsCents, &b.LifetimePaidCents, &b.UpdatedAt)
if err == sql.ErrNoRows {
return &Balance{TenantID: tenantID}, nil
}
if err != nil {
return nil, err
}
return &b, nil
}
func (s *Store) GetTenantsReadyForPayout(ctx context.Context, thresholdCents int) ([]Balance, error) {
rows, err := s.db.Query(ctx, `
SELECT b.tenant_id, b.available_cents, b.lifetime_earnings_cents, b.lifetime_paid_cents, b.updated_at
FROM balances b
JOIN payout_settings ps ON b.tenant_id = ps.tenant_id
WHERE b.available_cents >= $1 AND ps.wise_recipient_id IS NOT NULL
`, thresholdCents)
if err != nil {
return nil, err
}
defer rows.Close()
var balances []Balance
for rows.Next() {
var b Balance
if err := rows.Scan(&b.TenantID, &b.AvailableCents, &b.LifetimeEarningsCents, &b.LifetimePaidCents, &b.UpdatedAt); err != nil {
return nil, err
}
balances = append(balances, b)
}
return balances, rows.Err()
}
func (s *Store) DeductBalance(ctx context.Context, tenantID string, amountCents int) error {
_, err := s.db.Exec(ctx, `
UPDATE balances
SET available_cents = available_cents - $1,
lifetime_paid_cents = lifetime_paid_cents + $1,
updated_at = NOW()
WHERE tenant_id = $2 AND available_cents >= $1
`, amountCents, tenantID)
return err
}
func (s *Store) CreatePayout(ctx context.Context, tenantID string, amountCents int, currency, wiseTransferID, wiseQuoteID, status string) (string, error) {
var id string
err := s.db.QueryRow(ctx, `
INSERT INTO payouts (tenant_id, amount_cents, currency, wise_transfer_id, wise_quote_id, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, tenantID, amountCents, currency, wiseTransferID, wiseQuoteID, status).Scan(&id)
return id, err
}
func (s *Store) UpdatePayoutStatus(ctx context.Context, payoutID, status string, completedAt *time.Time, failureReason string) error {
_, err := s.db.Exec(ctx, `
UPDATE payouts
SET status = $1, completed_at = $2, failure_reason = $3
WHERE id = $4
`, status, completedAt, failureReason, payoutID)
return err
}
func (s *Store) GetPayoutSettings(ctx context.Context, tenantID string) (*PayoutSettings, error) {
var ps PayoutSettings
var recipientID, holderName, currency, email sql.NullString
err := s.db.QueryRow(ctx, `
SELECT tenant_id, wise_recipient_id, account_holder_name, currency, payout_email
FROM payout_settings WHERE tenant_id = $1
`, tenantID).Scan(&ps.TenantID, &recipientID, &holderName, &currency, &email)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
ps.WiseRecipientID = recipientID.String
ps.AccountHolderName = holderName.String
ps.Currency = currency.String
if ps.Currency == "" {
ps.Currency = "USD"
}
ps.PayoutEmail = email.String
return &ps, nil
}
func (s *Store) SavePayoutSettings(ctx context.Context, ps *PayoutSettings) error {
_, err := s.db.Exec(ctx, `
INSERT INTO payout_settings (tenant_id, wise_recipient_id, account_holder_name, currency, payout_email)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id) DO UPDATE
SET wise_recipient_id = $2,
account_holder_name = $3,
currency = $4,
payout_email = $5,
updated_at = NOW()
`, ps.TenantID, ps.WiseRecipientID, ps.AccountHolderName, ps.Currency, ps.PayoutEmail)
return err
}
type User struct {
ID string
Email string
Name string
}
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
var u User
err := s.db.QueryRow(ctx,
`SELECT id, email, COALESCE(name, '') FROM users WHERE id = $1`,
id).Scan(&u.ID, &u.Email, &u.Name)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}

303
internal/billing/webhook.go Normal file
View file

@ -0,0 +1,303 @@
package billing
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
)
type MemberSyncer interface {
SyncMember(ctx context.Context, tenantID, userID, email, name, tier, status string, expiresAt *time.Time) error
}
type WebhookHandler struct {
store *Store
lemon *LemonClient
memberSyncer MemberSyncer
}
func NewWebhookHandler(store *Store, lemonClient *LemonClient, syncer MemberSyncer) *WebhookHandler {
return &WebhookHandler{
store: store,
lemon: lemonClient,
memberSyncer: syncer,
}
}
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error("failed to read webhook body", "error", err)
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Signature")
if !h.lemon.VerifyWebhook(body, signature) {
slog.Warn("invalid webhook signature")
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
event, err := h.lemon.ParseWebhookEvent(body)
if err != nil {
slog.Error("failed to parse webhook event", "error", err)
http.Error(w, "Failed to parse event", http.StatusBadRequest)
return
}
ctx := r.Context()
switch event.Meta.EventName {
case "subscription_created":
err = h.handleSubscriptionCreated(ctx, event)
case "subscription_updated":
err = h.handleSubscriptionUpdated(ctx, event)
case "subscription_cancelled":
err = h.handleSubscriptionCancelled(ctx, event)
case "subscription_payment_success":
err = h.handleSubscriptionPayment(ctx, event)
case "order_created":
err = h.handleOrderCreated(ctx, event)
default:
slog.Debug("unhandled webhook event", "event", event.Meta.EventName)
}
if err != nil {
slog.Error("webhook handler error", "event", event.Meta.EventName, "error", err)
http.Error(w, "Handler error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *WebhookHandler) handleSubscriptionCreated(ctx context.Context, event *WebhookEvent) error {
data, err := event.GetSubscriptionData()
if err != nil {
return fmt.Errorf("parse subscription data: %w", err)
}
tenantID := event.Meta.CustomData["tenant_id"]
userID := event.Meta.CustomData["user_id"]
tierID := event.Meta.CustomData["tier_id"]
if tenantID == "" {
return fmt.Errorf("missing tenant_id in custom data")
}
sub := &Subscription{
TenantID: tenantID,
UserID: userID,
TierID: tierID,
TierName: data.Attributes.VariantName,
Status: normalizeStatus(data.Attributes.Status),
LemonSubscriptionID: data.ID,
LemonCustomerID: strconv.Itoa(data.Attributes.CustomerID),
AmountCents: data.Attributes.FirstSubscriptionItem.Price,
}
if data.Attributes.RenewsAt != "" {
if t, err := time.Parse(time.RFC3339, data.Attributes.RenewsAt); err == nil {
sub.CurrentPeriodEnd = t
}
}
sub.CurrentPeriodStart = time.Now()
if err := h.store.CreateSubscription(ctx, sub); err != nil {
return fmt.Errorf("create subscription: %w", err)
}
if h.memberSyncer != nil && userID != "" {
user, _ := h.store.GetUserByID(ctx, userID)
email, name := "", ""
if user != nil {
email, name = user.Email, user.Name
}
if err := h.memberSyncer.SyncMember(ctx, tenantID, userID, email, name, sub.TierName, "active", &sub.CurrentPeriodEnd); err != nil {
slog.Error("sync member failed", "tenant_id", tenantID, "user_id", userID, "error", err)
}
}
slog.Info("subscription created",
"tenant_id", tenantID,
"lemon_id", data.ID,
"tier", data.Attributes.VariantName,
)
return nil
}
func (h *WebhookHandler) handleSubscriptionUpdated(ctx context.Context, event *WebhookEvent) error {
data, err := event.GetSubscriptionData()
if err != nil {
return fmt.Errorf("parse subscription data: %w", err)
}
var renewsAt *time.Time
if data.Attributes.RenewsAt != "" {
if t, err := time.Parse(time.RFC3339, data.Attributes.RenewsAt); err == nil {
renewsAt = &t
}
}
status := normalizeStatus(data.Attributes.Status)
if err := h.store.UpdateSubscriptionStatus(ctx, data.ID, status, renewsAt); err != nil {
return fmt.Errorf("update subscription: %w", err)
}
if h.memberSyncer != nil {
sub, _ := h.store.GetSubscriptionByLemonID(ctx, data.ID)
if sub != nil && sub.UserID != "" {
user, _ := h.store.GetUserByID(ctx, sub.UserID)
email, name := "", ""
if user != nil {
email, name = user.Email, user.Name
}
if err := h.memberSyncer.SyncMember(ctx, sub.TenantID, sub.UserID, email, name, sub.TierName, status, renewsAt); err != nil {
slog.Error("sync member failed", "tenant_id", sub.TenantID, "user_id", sub.UserID, "error", err)
}
}
}
slog.Info("subscription updated", "lemon_id", data.ID, "status", status)
return nil
}
func (h *WebhookHandler) handleSubscriptionCancelled(ctx context.Context, event *WebhookEvent) error {
data, err := event.GetSubscriptionData()
if err != nil {
return fmt.Errorf("parse subscription data: %w", err)
}
sub, _ := h.store.GetSubscriptionByLemonID(ctx, data.ID)
if err := h.store.CancelSubscription(ctx, data.ID); err != nil {
return fmt.Errorf("cancel subscription: %w", err)
}
if h.memberSyncer != nil && sub != nil && sub.UserID != "" {
user, _ := h.store.GetUserByID(ctx, sub.UserID)
email, name := "", ""
if user != nil {
email, name = user.Email, user.Name
}
if err := h.memberSyncer.SyncMember(ctx, sub.TenantID, sub.UserID, email, name, sub.TierName, "cancelled", nil); err != nil {
slog.Error("sync member failed", "tenant_id", sub.TenantID, "user_id", sub.UserID, "error", err)
}
}
slog.Info("subscription cancelled", "lemon_id", data.ID)
return nil
}
func (h *WebhookHandler) handleSubscriptionPayment(ctx context.Context, event *WebhookEvent) error {
data, err := event.GetSubscriptionData()
if err != nil {
return fmt.Errorf("parse subscription data: %w", err)
}
sub, err := h.store.GetSubscriptionByLemonID(ctx, data.ID)
if err != nil {
return fmt.Errorf("get subscription: %w", err)
}
if sub == nil {
return fmt.Errorf("subscription not found: %s", data.ID)
}
grossCents := data.Attributes.FirstSubscriptionItem.Price
platformFeeCents := grossCents * 5 / 100
processorFeeCents := grossCents * 5 / 100
netCents := grossCents - platformFeeCents - processorFeeCents
description := fmt.Sprintf("%s subscription - %s", sub.TierName, time.Now().Format("January 2006"))
if err := h.store.AddEarnings(ctx, sub.TenantID, "subscription_payment", sub.ID, description, grossCents, platformFeeCents, processorFeeCents, netCents); err != nil {
return fmt.Errorf("add earnings: %w", err)
}
slog.Info("subscription payment recorded",
"tenant_id", sub.TenantID,
"gross_cents", grossCents,
"net_cents", netCents,
)
return nil
}
func (h *WebhookHandler) handleOrderCreated(ctx context.Context, event *WebhookEvent) error {
data, err := event.GetOrderData()
if err != nil {
return fmt.Errorf("parse order data: %w", err)
}
tenantID := event.Meta.CustomData["tenant_id"]
userID := event.Meta.CustomData["user_id"]
message := event.Meta.CustomData["message"]
if tenantID == "" {
return fmt.Errorf("missing tenant_id in custom data")
}
if event.Meta.CustomData["type"] != "donation" {
return nil
}
donation := &Donation{
TenantID: tenantID,
UserID: userID,
DonorEmail: data.Attributes.UserEmail,
DonorName: data.Attributes.UserName,
AmountCents: data.Attributes.TotalUsd,
LemonOrderID: data.ID,
Message: message,
}
if err := h.store.CreateDonation(ctx, donation); err != nil {
return fmt.Errorf("create donation: %w", err)
}
grossCents := data.Attributes.TotalUsd
platformFeeCents := grossCents * 5 / 100
processorFeeCents := grossCents * 5 / 100
netCents := grossCents - platformFeeCents - processorFeeCents
description := fmt.Sprintf("Donation from %s", data.Attributes.UserName)
if err := h.store.AddEarnings(ctx, tenantID, "donation", donation.ID, description, grossCents, platformFeeCents, processorFeeCents, netCents); err != nil {
return fmt.Errorf("add earnings: %w", err)
}
slog.Info("donation recorded",
"tenant_id", tenantID,
"donor", data.Attributes.UserEmail,
"gross_cents", grossCents,
)
return nil
}
func normalizeStatus(lemonStatus string) string {
switch lemonStatus {
case "on_trial", "active":
return "active"
case "paused", "past_due", "unpaid":
return "past_due"
case "cancelled", "expired":
return "cancelled"
default:
return lemonStatus
}
}

198
internal/billing/wise.go Normal file
View file

@ -0,0 +1,198 @@
package billing
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const wiseBaseURL = "https://api.transferwise.com"
type WiseClient struct {
apiKey string
profileID string
client *http.Client
}
func NewWiseClient() *WiseClient {
return &WiseClient{
apiKey: os.Getenv("WISE_API_KEY"),
profileID: os.Getenv("WISE_PROFILE_ID"),
client: &http.Client{},
}
}
func (c *WiseClient) IsConfigured() bool {
return c.apiKey != "" && c.profileID != ""
}
type Quote struct {
ID string `json:"id"`
SourceAmount float64 `json:"sourceAmount"`
TargetAmount float64 `json:"targetAmount"`
Rate float64 `json:"rate"`
Fee float64 `json:"fee"`
SourceCurrency string `json:"sourceCurrency"`
TargetCurrency string `json:"targetCurrency"`
}
type Transfer struct {
ID int64 `json:"id"`
Status string `json:"status"`
SourceValue float64 `json:"sourceValue"`
TargetValue float64 `json:"targetValue"`
}
func (c *WiseClient) CreateQuote(ctx context.Context, sourceCurrency, targetCurrency string, sourceAmountCents int) (*Quote, error) {
payload := map[string]any{
"sourceCurrency": sourceCurrency,
"targetCurrency": targetCurrency,
"sourceAmount": float64(sourceAmountCents) / 100,
"profile": c.profileID,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", wiseBaseURL+"/v3/quotes", bytes.NewReader(body))
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("quote creation failed (status %d): %s", resp.StatusCode, string(respBody))
}
var quote Quote
if err := json.Unmarshal(respBody, &quote); err != nil {
return nil, err
}
return &quote, nil
}
func (c *WiseClient) CreateTransfer(ctx context.Context, quoteID string, recipientID int64, reference string) (*Transfer, error) {
payload := map[string]any{
"targetAccount": recipientID,
"quoteUuid": quoteID,
"customerTransactionId": reference,
"details": map[string]any{"reference": reference},
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", wiseBaseURL+"/v1/transfers", bytes.NewReader(body))
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("transfer creation failed (status %d): %s", resp.StatusCode, string(respBody))
}
var transfer Transfer
if err := json.Unmarshal(respBody, &transfer); err != nil {
return nil, err
}
return &transfer, nil
}
func (c *WiseClient) FundTransfer(ctx context.Context, transferID int64) error {
url := fmt.Sprintf("%s/v3/profiles/%s/transfers/%d/payments", wiseBaseURL, c.profileID, transferID)
payload := map[string]any{"type": "BALANCE"}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return err
}
c.setHeaders(req)
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("funding failed (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
}
func (c *WiseClient) GetTransferStatus(ctx context.Context, transferID int64) (string, error) {
url := fmt.Sprintf("%s/v1/transfers/%d", wiseBaseURL, transferID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
c.setHeaders(req)
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("get transfer failed (status %d): %s", resp.StatusCode, string(respBody))
}
var transfer Transfer
if err := json.Unmarshal(respBody, &transfer); err != nil {
return "", err
}
return transfer.Status, nil
}
func (c *WiseClient) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
}

View file

@ -0,0 +1,15 @@
package assets
import (
"embed"
"io/fs"
"net/http"
)
//go:embed css js
var staticFS embed.FS
func Handler() http.Handler {
sub, _ := fs.Sub(staticFS, ".")
return http.FileServer(http.FS(sub))
}

View file

@ -0,0 +1,778 @@
/* WriteKit - Blog Stylesheet */
*, *::before, *::after {
box-sizing: border-box;
}
:root {
--accent: #2563eb;
--font-body: system-ui, -apple-system, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
--text: #18181b;
--text-muted: #71717a;
--bg: #ffffff;
--bg-secondary: #fafafa;
--border: #e4e4e7;
--content-width: 680px;
--spacing: 1.5rem;
/* Compactness defaults (cozy) */
--content-spacing: 1.75rem;
--paragraph-spacing: 1.25rem;
--heading-spacing: 2.5rem;
--line-height: 1.7;
}
/* Compactness: Compact */
.compactness-compact {
--content-spacing: 1.25rem;
--paragraph-spacing: 0.875rem;
--heading-spacing: 1.75rem;
--line-height: 1.55;
}
/* Compactness: Cozy (default) */
.compactness-cozy {
--content-spacing: 1.75rem;
--paragraph-spacing: 1.25rem;
--heading-spacing: 2.5rem;
--line-height: 1.7;
}
/* Compactness: Spacious */
.compactness-spacious {
--content-spacing: 2.25rem;
--paragraph-spacing: 1.5rem;
--heading-spacing: 3rem;
--line-height: 1.85;
}
/* Layout: Minimal */
.layout-minimal .site-header {
justify-content: center;
}
.layout-minimal .site-nav {
display: none;
}
.layout-minimal .site-footer {
border-top: none;
opacity: 0.7;
}
.layout-minimal .profile {
margin-bottom: 3.5rem;
}
/* Layout: Magazine */
.layout-magazine .posts-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem;
}
.layout-magazine .post-card {
padding: 1.5rem;
border: 1px solid var(--border);
background: var(--bg);
transition: border-color 0.2s, box-shadow 0.2s;
}
.layout-magazine .post-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.layout-magazine .post-card-title {
font-size: 1.0625rem;
}
.layout-magazine .post-card-description {
font-size: 0.875rem;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-body);
color: var(--text);
background: var(--bg);
line-height: 1.6;
}
a {
color: var(--accent);
text-decoration: none;
transition: color 0.15s;
}
a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Header */
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
max-width: var(--content-width);
margin: 0 auto;
padding: 2rem var(--spacing);
}
.site-name {
font-weight: 600;
font-size: 1rem;
color: var(--text);
letter-spacing: -0.01em;
}
.site-name:hover {
text-decoration: none;
color: var(--accent);
}
.site-nav {
display: flex;
gap: 1.5rem;
}
.site-nav a {
color: var(--text-muted);
font-size: 0.9375rem;
font-weight: 450;
}
.site-nav a:hover {
color: var(--text);
text-decoration: none;
}
/* Main */
main {
max-width: var(--content-width);
margin: 0 auto;
padding: 0 var(--spacing) 3rem;
}
/* Footer */
.site-footer {
max-width: var(--content-width);
margin: 0 auto;
padding: 2.5rem var(--spacing);
text-align: center;
color: var(--text-muted);
font-size: 0.8125rem;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.powered-by {
color: var(--text-muted);
text-decoration: none;
font-size: 0.75rem;
opacity: 0.7;
transition: opacity 0.2s;
}
.powered-by:hover {
opacity: 1;
}
/* Profile */
.profile {
text-align: center;
margin-bottom: 3rem;
}
.profile-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
margin-bottom: 1rem;
}
.profile-name {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.25rem;
letter-spacing: -0.01em;
}
.profile-bio {
color: var(--text-muted);
max-width: 380px;
margin: 0 auto;
font-size: 0.9375rem;
line-height: 1.6;
}
/* Post List */
.posts-list {
display: flex;
flex-direction: column;
gap: var(--content-spacing);
}
.post-card a {
display: block;
color: inherit;
}
.post-card a:hover {
text-decoration: none;
}
.post-card-date {
font-size: 0.8125rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.post-card-title {
margin: 0.375rem 0 0;
font-size: 1.1875rem;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.35;
}
.post-card a:hover .post-card-title {
color: var(--accent);
}
.post-card-description {
margin: 0.5rem 0 0;
color: var(--text-muted);
font-size: 0.9375rem;
line-height: 1.55;
}
.no-posts {
color: var(--text-muted);
text-align: center;
padding: 3rem 0;
}
/* Pagination */
.pagination {
display: flex;
justify-content: space-between;
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.9375rem;
}
/* Post Header */
.post-header {
margin-bottom: 2.5rem;
}
.post-date {
font-size: 0.8125rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.post-title {
margin: 0.5rem 0 0;
font-size: 2.25rem;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.025em;
}
.post-description {
font-size: 1.125rem;
color: var(--text-muted);
margin: 0.75rem 0 0;
line-height: 1.5;
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.25rem;
}
.tag {
font-size: 0.8125rem;
color: var(--text-muted);
background: var(--bg-secondary);
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
}
.post-cover {
margin: 1.75rem 0 0;
}
.post-cover img {
width: 100%;
height: auto;
border-radius: 0.5rem;
aspect-ratio: 16 / 9;
object-fit: cover;
}
/* Prose */
.prose {
line-height: var(--line-height);
font-size: 1.0625rem;
}
.prose > *:first-child {
margin-top: 0;
}
.prose h2 {
margin-top: var(--heading-spacing);
margin-bottom: var(--paragraph-spacing);
font-size: 1.5rem;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.3;
}
.prose h3 {
margin-top: calc(var(--heading-spacing) * 0.8);
margin-bottom: calc(var(--paragraph-spacing) * 0.8);
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.35;
}
.prose h4 {
margin-top: calc(var(--heading-spacing) * 0.65);
margin-bottom: calc(var(--paragraph-spacing) * 0.65);
font-size: 1.0625rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.prose p {
margin: var(--paragraph-spacing) 0;
}
.prose ul, .prose ol {
margin: var(--paragraph-spacing) 0;
padding-left: 1.375rem;
}
.prose li {
margin: calc(var(--paragraph-spacing) * 0.4) 0;
padding-left: 0.25rem;
}
.prose li::marker {
color: var(--text-muted);
}
.prose blockquote {
margin: var(--content-spacing) 0;
padding: 0 0 0 1.25rem;
border-left: 2px solid var(--border);
color: var(--text-muted);
}
.prose blockquote p {
margin: 0;
}
.prose pre {
margin: var(--content-spacing) 0;
padding: 1.125rem 1.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.375rem;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.6;
}
.prose code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--bg-secondary);
padding: 0.175rem 0.375rem;
border-radius: 0.25rem;
font-variant-ligatures: none;
}
.prose pre code {
background: none;
padding: 0;
font-size: inherit;
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: var(--content-spacing) 0;
}
.prose a {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.prose a:hover {
text-decoration-color: var(--accent);
}
.prose hr {
margin: var(--heading-spacing) 0;
border: none;
border-top: 1px solid var(--border);
}
.prose strong {
font-weight: 600;
}
.prose table {
width: 100%;
margin: var(--content-spacing) 0;
border-collapse: collapse;
font-size: 0.9375rem;
}
.prose th, .prose td {
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
text-align: left;
}
.prose th {
background: var(--bg-secondary);
font-weight: 600;
}
/* Reactions & Comments */
.reactions, .comments {
margin-top: 3.5rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
.comments-title {
margin: 0 0 1.25rem;
font-size: 1.125rem;
font-weight: 600;
}
.reaction-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.reaction-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid var(--border);
border-radius: 2rem;
background: var(--bg);
cursor: pointer;
font-size: 1rem;
transition: all 0.15s;
}
.reaction-btn:hover {
background: var(--bg-secondary);
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
}
.reaction-btn.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.reaction-btn .count {
font-size: 0.8125rem;
font-weight: 500;
}
/* Search */
.search-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
color: var(--text-muted);
cursor: pointer;
font-size: 0.875rem;
transition: border-color 0.15s;
}
.search-trigger:hover {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
}
.search-trigger kbd {
padding: 0.125rem 0.375rem;
background: var(--bg-secondary);
border-radius: 0.25rem;
font-family: var(--font-mono);
font-size: 0.6875rem;
}
.search-modal {
display: none;
position: fixed;
inset: 0;
z-index: 100;
}
.search-modal.active {
display: block;
}
.search-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
}
.search-modal-content {
position: absolute;
top: 15%;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 520px;
background: var(--bg);
border-radius: 0.5rem;
box-shadow: 0 20px 40px -8px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
#search-input {
width: 100%;
padding: 1rem 1.25rem;
border: none;
font-size: 1rem;
outline: none;
background: var(--bg);
color: var(--text);
}
#search-input::placeholder {
color: var(--text-muted);
}
.search-results {
max-height: 320px;
overflow-y: auto;
border-top: 1px solid var(--border);
}
.search-result {
border-bottom: 1px solid var(--border);
}
.search-result:last-child {
border-bottom: none;
}
.search-result a {
display: block;
padding: 0.875rem 1.25rem;
color: inherit;
}
.search-result a:hover {
text-decoration: none;
}
.search-result:hover, .search-result.focused {
background: var(--bg-secondary);
}
.search-result-title {
font-weight: 500;
font-size: 0.9375rem;
}
.search-result-snippet {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
line-height: 1.5;
}
.search-result mark {
background: color-mix(in srgb, var(--accent) 25%, transparent);
color: inherit;
border-radius: 0.125rem;
padding: 0 0.125rem;
}
.search-hint {
padding: 0.625rem 1.25rem;
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
border-top: 1px solid var(--border);
}
.search-hint kbd {
padding: 0.125rem 0.375rem;
background: var(--bg-secondary);
border-radius: 0.25rem;
font-family: var(--font-mono);
}
.search-no-results {
padding: 1.5rem 1.25rem;
text-align: center;
color: var(--text-muted);
font-size: 0.9375rem;
}
/* Comment Form */
.comment-form textarea {
width: 100%;
padding: 0.875rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-family: inherit;
font-size: 0.9375rem;
resize: vertical;
min-height: 100px;
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.comment-form textarea:focus {
outline: none;
border-color: var(--accent);
}
.comment-form button {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.15s;
}
.comment-form button:hover {
opacity: 0.9;
}
/* Comments List */
.comments-list {
margin-top: 1.5rem;
}
.comment {
padding: 1.25rem 0;
border-bottom: 1px solid var(--border);
}
.comment:first-child {
padding-top: 0;
}
.comment:last-child {
border-bottom: none;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.625rem;
}
.comment-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
}
.comment-author {
font-weight: 500;
font-size: 0.9375rem;
}
.comment-date {
font-size: 0.8125rem;
color: var(--text-muted);
}
.comment-content {
font-size: 0.9375rem;
line-height: 1.6;
}
/* Auth Prompt */
.auth-prompt {
padding: 1.25rem;
background: var(--bg-secondary);
border-radius: 0.375rem;
text-align: center;
font-size: 0.9375rem;
color: var(--text-muted);
}
.auth-prompt a {
font-weight: 500;
}
/* Mobile */
@media (max-width: 640px) {
:root {
--spacing: 1.25rem;
}
.site-header {
padding: 1.5rem var(--spacing);
}
.post-title {
font-size: 1.75rem;
}
.prose {
font-size: 1rem;
}
.prose h2 {
font-size: 1.375rem;
}
.prose h3 {
font-size: 1.125rem;
}
}

View file

@ -0,0 +1,127 @@
(function() {
'use strict';
// Live reload when studio saves settings
const channel = new BroadcastChannel('writekit-studio');
channel.onmessage = function(event) {
if (event.data.type === 'settings-changed') {
location.reload();
}
};
document.addEventListener('DOMContentLoaded', initSearch);
function initSearch() {
const trigger = document.getElementById('search-trigger');
const modal = document.getElementById('search-modal');
const backdrop = modal?.querySelector('.search-modal-backdrop');
const input = document.getElementById('search-input');
const results = document.getElementById('search-results');
if (!trigger || !modal || !input || !results) return;
let debounceTimer;
function open() {
modal.classList.add('active');
document.body.style.overflow = 'hidden';
input.value = '';
results.innerHTML = '';
setTimeout(() => input.focus(), 10);
}
function close() {
modal.classList.remove('active');
document.body.style.overflow = '';
}
trigger.addEventListener('click', open);
backdrop.addEventListener('click', close);
document.addEventListener('keydown', function(e) {
if (e.key === '/' && !modal.classList.contains('active') &&
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
e.preventDefault();
open();
}
if (e.key === 'Escape' && modal.classList.contains('active')) {
close();
}
});
input.addEventListener('input', function() {
const query = this.value.trim();
clearTimeout(debounceTimer);
if (query.length < 2) {
results.innerHTML = '';
return;
}
debounceTimer = setTimeout(() => search(query), 150);
});
input.addEventListener('keydown', function(e) {
const items = results.querySelectorAll('.search-result');
const focused = results.querySelector('.search-result.focused');
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!focused && items.length) {
items[0].classList.add('focused');
} else if (focused?.nextElementSibling) {
focused.classList.remove('focused');
focused.nextElementSibling.classList.add('focused');
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (focused?.previousElementSibling) {
focused.classList.remove('focused');
focused.previousElementSibling.classList.add('focused');
}
} else if (e.key === 'Enter' && focused) {
e.preventDefault();
const link = focused.querySelector('a');
if (link) window.location.href = link.href;
}
});
async function search(query) {
try {
const res = await fetch('/api/reader/search?q=' + encodeURIComponent(query));
const data = await res.json();
if (!data || data.length === 0) {
results.innerHTML = '<div class="search-no-results">No results found</div>';
return;
}
results.innerHTML = data.map(r => `
<div class="search-result">
<a href="${r.url}">
<div class="search-result-title">${highlight(r.title, query)}</div>
${r.description ? `<div class="search-result-snippet">${highlight(r.description, query)}</div>` : ''}
</a>
</div>
`).join('');
} catch (e) {
results.innerHTML = '<div class="search-no-results">Search failed</div>';
}
}
function highlight(text, query) {
if (!text) return '';
const escaped = escapeHtml(text);
const tokens = query.split(/\s+/).filter(t => t.length > 0);
if (!tokens.length) return escaped;
const pattern = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
return escaped.replace(new RegExp(`(${pattern})`, 'gi'), '<mark>$1</mark>');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
})();

View file

@ -0,0 +1,197 @@
var WriteKit = (function() {
'use strict';
let config = {};
let user = null;
async function init(opts) {
config = opts;
try {
const res = await fetch('/api/reader/me');
const data = await res.json();
if (data.logged_in) {
user = data.user;
}
} catch (e) {}
if (config.reactions) initReactions();
if (config.comments) initComments();
}
async function initReactions() {
const container = document.querySelector('.reactions-container');
if (!container) return;
const res = await fetch(`/api/reader/posts/${config.slug}/reactions`);
const data = await res.json();
const counts = data.counts || {};
const userReactions = data.user || [];
if (config.reactionMode === 'upvote') {
const emoji = config.reactionEmojis[0] || '👍';
const count = counts[emoji] || 0;
const active = userReactions.includes(emoji);
container.innerHTML = `
<button class="reaction-btn ${active ? 'active' : ''}" data-emoji="${emoji}">
<span class="emoji">${emoji}</span>
<span class="count">${count}</span>
</button>
`;
} else {
container.innerHTML = config.reactionEmojis.map(emoji => {
const count = counts[emoji] || 0;
const active = userReactions.includes(emoji);
return `
<button class="reaction-btn ${active ? 'active' : ''}" data-emoji="${emoji}">
<span class="emoji">${emoji}</span>
<span class="count">${count}</span>
</button>
`;
}).join('');
}
container.querySelectorAll('.reaction-btn').forEach(btn => {
btn.addEventListener('click', () => toggleReaction(btn));
});
}
async function toggleReaction(btn) {
if (config.requireAuth && !user) {
showAuthPrompt('reactions');
return;
}
const emoji = btn.dataset.emoji;
try {
const res = await fetch(`/api/reader/posts/${config.slug}/reactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
});
if (res.status === 401) {
showAuthPrompt('reactions');
return;
}
const data = await res.json();
const countEl = btn.querySelector('.count');
const current = parseInt(countEl.textContent) || 0;
if (data.added) {
btn.classList.add('active');
countEl.textContent = current + 1;
} else {
btn.classList.remove('active');
countEl.textContent = Math.max(0, current - 1);
}
} catch (e) {
console.error('Failed to toggle reaction', e);
}
}
async function initComments() {
const section = document.querySelector('.comments');
if (!section) return;
const list = section.querySelector('.comments-list');
const formContainer = section.querySelector('.comment-form-container');
const res = await fetch(`/api/reader/posts/${config.slug}/comments`);
const comments = await res.json();
if (comments && comments.length > 0) {
list.innerHTML = comments.map(renderComment).join('');
} else {
list.innerHTML = '<p class="no-comments">No comments yet. Be the first!</p>';
}
if (user) {
formContainer.innerHTML = `
<form class="comment-form">
<textarea placeholder="Write a comment..." rows="3" required></textarea>
<button type="submit">Post Comment</button>
</form>
`;
formContainer.querySelector('form').addEventListener('submit', submitComment);
} else {
formContainer.innerHTML = `
<div class="auth-prompt">
<a href="/api/reader/login/github?redirect=${encodeURIComponent(window.location.pathname)}">Sign in</a> to leave a comment
</div>
`;
}
}
function renderComment(comment) {
const date = new Date(comment.created_at).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
return `
<div class="comment" data-id="${comment.id}">
<div class="comment-header">
${comment.avatar_url ? `<img src="${comment.avatar_url}" alt="" class="comment-avatar">` : ''}
<span class="comment-author">${escapeHtml(comment.name || 'Anonymous')}</span>
<span class="comment-date">${date}</span>
</div>
<div class="comment-content">${comment.content_html || escapeHtml(comment.content)}</div>
</div>
`;
}
async function submitComment(e) {
e.preventDefault();
const form = e.target;
const textarea = form.querySelector('textarea');
const content = textarea.value.trim();
if (!content) return;
const btn = form.querySelector('button');
btn.disabled = true;
btn.textContent = 'Posting...';
try {
const res = await fetch(`/api/reader/posts/${config.slug}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (res.status === 401) {
showAuthPrompt('comments');
return;
}
const comment = await res.json();
const list = document.querySelector('.comments-list');
const noComments = list.querySelector('.no-comments');
if (noComments) noComments.remove();
list.insertAdjacentHTML('beforeend', renderComment(comment));
textarea.value = '';
} catch (e) {
console.error('Failed to post comment', e);
} finally {
btn.disabled = false;
btn.textContent = 'Post Comment';
}
}
function showAuthPrompt(feature) {
alert(`Please sign in to use ${feature}`);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
return { init };
})();

View file

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<meta name="description" content="{{.Description}}">
<link rel="canonical" href="{{.CanonicalURL}}">
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
<!-- Open Graph -->
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:type" content="{{.OGType}}">
<meta property="og:url" content="{{.CanonicalURL}}">
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}">{{end}}
<meta property="og:site_name" content="{{.SiteName}}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.Title}}">
<meta name="twitter:description" content="{{.Description}}">
{{if .OGImage}}<meta name="twitter:image" content="{{.OGImage}}">{{end}}
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
{{if .FontURL}}<link rel="stylesheet" href="{{.FontURL}}">{{end}}
<link rel="stylesheet" href="/static/css/style.css">
<style>
:root {
--accent: {{with index .Settings "accent_color"}}{{.}}{{else}}#2563eb{{end}};
--font-body: {{or .FontFamily "system-ui, -apple-system, sans-serif"}};
}
</style>
{{if .StructuredData}}
<script type="application/ld+json">{{.StructuredData}}</script>
{{end}}
</head>
<body class="layout-{{with index .Settings "layout"}}{{.}}{{else}}default{{end}} compactness-{{with index .Settings "compactness"}}{{.}}{{else}}cozy{{end}}">
<header class="site-header">
<a href="/" class="site-name">{{.SiteName}}</a>
<nav class="site-nav">
<button type="button" id="search-trigger" class="search-trigger">
<span>Search</span>
<kbd>/</kbd>
</button>
</nav>
</header>
<main>
{{template "content" .}}
</main>
<footer class="site-footer">
<span>&copy; {{.Year}} {{.SiteName}}</span>
{{if .ShowBadge}}<a href="https://writekit.dev" class="powered-by" target="_blank" rel="noopener">Powered by WriteKit</a>{{end}}
</footer>
<div id="search-modal" class="search-modal">
<div class="search-modal-backdrop"></div>
<div class="search-modal-content">
<input type="text" id="search-input" placeholder="Search..." autocomplete="off">
<div id="search-results" class="search-results"></div>
<div class="search-hint">Press <kbd>ESC</kbd> to close</div>
</div>
</div>
<script src="/static/js/main.js"></script>
{{block "scripts" .}}{{end}}
</body>
</html>

View file

@ -0,0 +1,34 @@
{{define "content"}}
<div class="blog">
<header class="blog-header">
<h1>Posts</h1>
</header>
<section class="posts-list">
{{range .Posts}}
<article class="post-card">
<a href="/posts/{{.Slug}}">
<h2 class="post-card-title">{{.Title}}</h2>
<time class="post-card-date" datetime="{{.Date.Format "2006-01-02"}}">{{.Date.Format "January 2, 2006"}}</time>
{{if .Description}}
<p class="post-card-description">{{.Description}}</p>
{{end}}
</a>
</article>
{{else}}
<p class="no-posts">No posts yet.</p>
{{end}}
</section>
{{if or .PrevPage .NextPage}}
<nav class="pagination">
{{if .PrevPage}}
<a href="{{.PrevPage}}" class="pagination-prev">&larr; Newer</a>
{{end}}
{{if .NextPage}}
<a href="{{.NextPage}}" class="pagination-next">Older &rarr;</a>
{{end}}
</nav>
{{end}}
</div>
{{end}}

View file

@ -0,0 +1,34 @@
{{define "content"}}
<div class="home">
{{with index .Settings "author_bio"}}
<section class="profile">
{{with index $.Settings "author_avatar"}}
<img src="{{.}}" alt="{{$.SiteName}}" class="profile-avatar">
{{end}}
<p class="profile-bio">{{.}}</p>
</section>
{{end}}
<section class="posts-list">
{{range .Posts}}
<article class="post-card">
<a href="/posts/{{.Slug}}">
<h2 class="post-card-title">{{.Title}}</h2>
<time class="post-card-date" datetime="{{.Date.Format "2006-01-02"}}">{{.Date.Format "January 2, 2006"}}</time>
{{if .Description}}
<p class="post-card-description">{{.Description}}</p>
{{end}}
</a>
</article>
{{else}}
<p class="no-posts">No posts yet.</p>
{{end}}
</section>
{{if .HasMore}}
<nav class="pagination">
<a href="/posts" class="view-all">View all posts</a>
</nav>
{{end}}
</div>
{{end}}

View file

@ -0,0 +1,57 @@
{{define "content"}}
<article class="post">
<header class="post-header">
<time class="post-date" datetime="{{.Post.Date.Format "2006-01-02"}}">{{.Post.Date.Format "January 2, 2006"}}</time>
<h1 class="post-title">{{.Post.Title}}</h1>
{{if .Post.Description}}
<p class="post-description">{{.Post.Description}}</p>
{{end}}
{{if .Post.Tags}}
<div class="post-tags">
{{range .Post.Tags}}
<a href="/tags/{{.}}" class="tag">#{{.}}</a>
{{end}}
</div>
{{end}}
{{if .Post.CoverImage}}
<figure class="post-cover">
<img src="{{.Post.CoverImage}}" alt="{{.Post.Title}}" loading="eager" />
</figure>
{{end}}
</header>
<div class="post-content prose">
{{.ContentHTML}}
</div>
{{if .InteractionConfig.ReactionsEnabled}}
<section id="reactions" class="reactions" data-slug="{{.Post.Slug}}">
<div class="reactions-container"></div>
</section>
{{end}}
{{if .InteractionConfig.CommentsEnabled}}
<section id="comments" class="comments" data-slug="{{.Post.Slug}}">
<h3 class="comments-title">Comments</h3>
<div class="comments-list"></div>
<div class="comment-form-container"></div>
</section>
{{end}}
</article>
{{end}}
{{define "scripts"}}
{{if or .InteractionConfig.ReactionsEnabled .InteractionConfig.CommentsEnabled}}
<script src="/static/js/post.js"></script>
<script>
WriteKit.init({
slug: "{{.Post.Slug}}",
reactions: {{.InteractionConfig.ReactionsEnabled}},
comments: {{.InteractionConfig.CommentsEnabled}},
reactionMode: "{{.InteractionConfig.ReactionMode}}",
reactionEmojis: "{{.InteractionConfig.ReactionEmojis}}".split(","),
requireAuth: {{.InteractionConfig.ReactionsRequireAuth}}
});
</script>
{{end}}
{{end}}

View file

@ -0,0 +1,139 @@
package templates
import (
"bytes"
"embed"
"encoding/json"
"html/template"
"time"
)
//go:embed *.html
var templateFS embed.FS
var funcMap = template.FuncMap{
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"json": func(v any) string { b, _ := json.Marshal(v); return string(b) },
"or": func(a, b any) any {
if a != nil && a != "" {
return a
}
return b
},
}
var fontURLs = map[string]string{
"system": "",
"inter": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
"georgia": "",
"merriweather": "https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap",
"source-serif": "https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;600;700&display=swap",
"jetbrains-mono": "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap",
}
var fontFamilies = map[string]string{
"system": "system-ui, -apple-system, sans-serif",
"inter": "'Inter', system-ui, sans-serif",
"georgia": "Georgia, 'Times New Roman', serif",
"merriweather": "'Merriweather', Georgia, serif",
"source-serif": "'Source Serif 4', Georgia, serif",
"jetbrains-mono": "'JetBrains Mono', 'Fira Code', monospace",
}
func GetFontURL(fontKey string) string {
if url, ok := fontURLs[fontKey]; ok {
return url
}
return ""
}
func GetFontFamily(fontKey string) string {
if family, ok := fontFamilies[fontKey]; ok {
return family
}
return fontFamilies["system"]
}
var homeTemplate, blogTemplate, postTemplate *template.Template
func init() {
homeTemplate = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "base.html", "home.html"))
blogTemplate = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "base.html", "blog.html"))
postTemplate = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "base.html", "post.html"))
}
type PageData struct {
Title string
Description string
CanonicalURL string
OGType string
OGImage string
NoIndex bool
SiteName string
Year int
FontURL string
FontFamily string
StructuredData template.JS
Settings map[string]any
ShowBadge bool
}
type HomeData struct {
PageData
Posts []PostSummary
HasMore bool
}
type BlogData struct {
PageData
Posts []PostSummary
PrevPage string
NextPage string
}
type PostData struct {
PageData
Post PostDetail
ContentHTML template.HTML
InteractionConfig map[string]any
}
type PostSummary struct {
Slug string
Title string
Description string
Date time.Time
}
type PostDetail struct {
Slug string
Title string
Description string
CoverImage string
Date time.Time
Tags []string
}
func RenderHome(data HomeData) ([]byte, error) {
var buf bytes.Buffer
if err := homeTemplate.ExecuteTemplate(&buf, "base.html", data); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func RenderBlog(data BlogData) ([]byte, error) {
var buf bytes.Buffer
if err := blogTemplate.ExecuteTemplate(&buf, "base.html", data); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func RenderPost(data PostData) ([]byte, error) {
var buf bytes.Buffer
if err := postTemplate.ExecuteTemplate(&buf, "base.html", data); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

View file

@ -0,0 +1,451 @@
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
)
type Client struct {
apiToken string
zoneID string
client *http.Client
cache *analyticsCache
}
type analyticsCache struct {
mu sync.RWMutex
data map[string]*cachedResult
ttl time.Duration
}
type cachedResult struct {
result *ZoneAnalytics
expiresAt time.Time
}
func NewClient() *Client {
return &Client{
apiToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
zoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
client: &http.Client{Timeout: 30 * time.Second},
cache: &analyticsCache{
data: make(map[string]*cachedResult),
ttl: 5 * time.Minute,
},
}
}
func (c *Client) IsConfigured() bool {
return c.apiToken != "" && c.zoneID != ""
}
type ZoneAnalytics struct {
TotalRequests int64 `json:"totalRequests"`
TotalPageViews int64 `json:"totalPageViews"`
UniqueVisitors int64 `json:"uniqueVisitors"`
TotalBandwidth int64 `json:"totalBandwidth"`
Daily []DailyStats `json:"daily"`
Browsers []NamedCount `json:"browsers"`
OS []NamedCount `json:"os"`
Devices []NamedCount `json:"devices"`
Countries []NamedCount `json:"countries"`
Paths []PathStats `json:"paths"`
}
type DailyStats struct {
Date string `json:"date"`
Requests int64 `json:"requests"`
PageViews int64 `json:"pageViews"`
Visitors int64 `json:"visitors"`
Bandwidth int64 `json:"bandwidth"`
}
type NamedCount struct {
Name string `json:"name"`
Count int64 `json:"count"`
}
type PathStats struct {
Path string `json:"path"`
Requests int64 `json:"requests"`
}
type graphqlRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
type graphqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors,omitempty"`
}
func (c *Client) GetAnalytics(ctx context.Context, days int, hostname string) (*ZoneAnalytics, error) {
if !c.IsConfigured() {
return nil, fmt.Errorf("cloudflare not configured")
}
cacheKey := fmt.Sprintf("%s:%d:%s", c.zoneID, days, hostname)
if cached := c.cache.get(cacheKey); cached != nil {
return cached, nil
}
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
until := time.Now().Format("2006-01-02")
result := &ZoneAnalytics{}
dailyData, err := c.fetchDailyStats(ctx, since, until, hostname)
if err != nil {
return nil, err
}
result.Daily = dailyData
for _, d := range dailyData {
result.TotalRequests += d.Requests
result.TotalPageViews += d.PageViews
result.UniqueVisitors += d.Visitors
result.TotalBandwidth += d.Bandwidth
}
browsers, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "userAgentBrowser")
result.Browsers = browsers
osStats, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "userAgentOS")
result.OS = osStats
devices, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "deviceType")
result.Devices = devices
countries, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "clientCountryName")
result.Countries = countries
paths, _ := c.fetchPathStats(ctx, since, until, hostname)
result.Paths = paths
c.cache.set(cacheKey, result)
return result, nil
}
func (c *Client) fetchDailyStats(ctx context.Context, since, until, hostname string) ([]DailyStats, error) {
query := `
query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequests1dGroups(
filter: { date_geq: $since, date_leq: $until, clientRequestHTTPHost: $hostname }
orderBy: [date_ASC]
limit: 100
) {
dimensions {
date
}
sum {
requests
pageViews
bytes
}
uniq {
uniques
}
}
}
}
}
`
vars := map[string]any{
"zoneTag": c.zoneID,
"since": since,
"until": until,
"hostname": hostname,
}
resp, err := c.doQuery(ctx, query, vars)
if err != nil {
return nil, err
}
var data struct {
Viewer struct {
Zones []struct {
HttpRequests1dGroups []struct {
Dimensions struct {
Date string `json:"date"`
} `json:"dimensions"`
Sum struct {
Requests int64 `json:"requests"`
PageViews int64 `json:"pageViews"`
Bytes int64 `json:"bytes"`
} `json:"sum"`
Uniq struct {
Uniques int64 `json:"uniques"`
} `json:"uniq"`
} `json:"httpRequests1dGroups"`
} `json:"zones"`
} `json:"viewer"`
}
if err := json.Unmarshal(resp, &data); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
var results []DailyStats
if len(data.Viewer.Zones) > 0 {
for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
results = append(results, DailyStats{
Date: g.Dimensions.Date,
Requests: g.Sum.Requests,
PageViews: g.Sum.PageViews,
Visitors: g.Uniq.Uniques,
Bandwidth: g.Sum.Bytes,
})
}
}
return results, nil
}
func (c *Client) fetchGroupedStats(ctx context.Context, since, until, hostname, hostField, groupBy string) ([]NamedCount, error) {
query := fmt.Sprintf(`
query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequests1dGroups(
filter: { date_geq: $since, date_leq: $until, %s: $hostname }
orderBy: [sum_requests_DESC]
limit: 10
) {
dimensions {
%s
}
sum {
requests
}
}
}
}
}
`, hostField, groupBy)
vars := map[string]any{
"zoneTag": c.zoneID,
"since": since,
"until": until,
"hostname": hostname,
}
resp, err := c.doQuery(ctx, query, vars)
if err != nil {
return nil, err
}
var data struct {
Viewer struct {
Zones []struct {
HttpRequests1dGroups []struct {
Dimensions map[string]string `json:"dimensions"`
Sum struct {
Requests int64 `json:"requests"`
} `json:"sum"`
} `json:"httpRequests1dGroups"`
} `json:"zones"`
} `json:"viewer"`
}
if err := json.Unmarshal(resp, &data); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
var results []NamedCount
if len(data.Viewer.Zones) > 0 {
for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
name := g.Dimensions[groupBy]
if name == "" {
name = "Unknown"
}
results = append(results, NamedCount{
Name: name,
Count: g.Sum.Requests,
})
}
}
return results, nil
}
func (c *Client) fetchPathStats(ctx context.Context, since, until, hostname string) ([]PathStats, error) {
query := `
query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequests1dGroups(
filter: { date_geq: $since, date_leq: $until, clientRequestHTTPHost: $hostname }
orderBy: [sum_requests_DESC]
limit: 20
) {
dimensions {
clientRequestPath
}
sum {
requests
}
}
}
}
}
`
vars := map[string]any{
"zoneTag": c.zoneID,
"since": since,
"until": until,
"hostname": hostname,
}
resp, err := c.doQuery(ctx, query, vars)
if err != nil {
return nil, err
}
var data struct {
Viewer struct {
Zones []struct {
HttpRequests1dGroups []struct {
Dimensions struct {
Path string `json:"clientRequestPath"`
} `json:"dimensions"`
Sum struct {
Requests int64 `json:"requests"`
} `json:"sum"`
} `json:"httpRequests1dGroups"`
} `json:"zones"`
} `json:"viewer"`
}
if err := json.Unmarshal(resp, &data); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
var results []PathStats
if len(data.Viewer.Zones) > 0 {
for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
results = append(results, PathStats{
Path: g.Dimensions.Path,
Requests: g.Sum.Requests,
})
}
}
return results, nil
}
func (c *Client) doQuery(ctx context.Context, query string, vars map[string]any) (json.RawMessage, error) {
reqBody := graphqlRequest{
Query: query,
Variables: vars,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.cloudflare.com/client/v4/graphql", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiToken)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cloudflare API error: %s", string(respBody))
}
var gqlResp graphqlResponse
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, err
}
if len(gqlResp.Errors) > 0 {
return nil, fmt.Errorf("graphql error: %s", gqlResp.Errors[0].Message)
}
return gqlResp.Data, nil
}
func (c *analyticsCache) get(key string) *ZoneAnalytics {
c.mu.RLock()
defer c.mu.RUnlock()
if cached, ok := c.data[key]; ok && time.Now().Before(cached.expiresAt) {
return cached.result
}
return nil
}
func (c *analyticsCache) set(key string, result *ZoneAnalytics) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = &cachedResult{
result: result,
expiresAt: time.Now().Add(c.ttl),
}
}
func (c *Client) PurgeURLs(ctx context.Context, urls []string) error {
if !c.IsConfigured() || len(urls) == 0 {
return nil
}
body, err := json.Marshal(map[string][]string{"files": urls})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST",
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/purge_cache", c.zoneID),
bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiToken)
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("cloudflare purge failed: %s", string(respBody))
}
return nil
}

100
internal/config/tiers.go Normal file
View file

@ -0,0 +1,100 @@
package config
type Tier string
const (
TierFree Tier = "free"
TierPro Tier = "pro"
)
type TierConfig struct {
Name string `json:"name"`
Description string `json:"description"`
MonthlyPrice int `json:"monthly_price"`
AnnualPrice int `json:"annual_price"`
CustomDomain bool `json:"custom_domain"`
BadgeRequired bool `json:"badge_required"`
AnalyticsRetention int `json:"analytics_retention"`
APIRateLimit int `json:"api_rate_limit"`
MaxWebhooks int `json:"max_webhooks"`
WebhookDeliveries int `json:"webhook_deliveries"`
MaxPlugins int `json:"max_plugins"`
PluginExecutions int `json:"plugin_executions"`
}
var Tiers = map[Tier]TierConfig{
TierFree: {
Name: "Free",
Description: "For getting started",
MonthlyPrice: 0,
AnnualPrice: 0,
CustomDomain: false,
BadgeRequired: true,
AnalyticsRetention: 7,
APIRateLimit: 100,
MaxWebhooks: 3,
WebhookDeliveries: 100,
MaxPlugins: 3,
PluginExecutions: 1000,
},
TierPro: {
Name: "Pro",
Description: "For serious bloggers",
MonthlyPrice: 500,
AnnualPrice: 4900,
CustomDomain: true,
BadgeRequired: false,
AnalyticsRetention: 90,
APIRateLimit: 1000,
MaxWebhooks: 10,
WebhookDeliveries: 1000,
MaxPlugins: 10,
PluginExecutions: 10000,
},
}
func GetTier(premium bool) Tier {
if premium {
return TierPro
}
return TierFree
}
func GetConfig(tier Tier) TierConfig {
if cfg, ok := Tiers[tier]; ok {
return cfg
}
return Tiers[TierFree]
}
type TierInfo struct {
Tier Tier `json:"tier"`
Config TierConfig `json:"config"`
}
func GetTierInfo(premium bool) TierInfo {
tier := GetTier(premium)
return TierInfo{
Tier: tier,
Config: GetConfig(tier),
}
}
type Usage struct {
Webhooks int `json:"webhooks"`
Plugins int `json:"plugins"`
}
type AllTiersResponse struct {
CurrentTier Tier `json:"current_tier"`
Tiers map[Tier]TierConfig `json:"tiers"`
Usage Usage `json:"usage"`
}
func GetAllTiers(premium bool, usage Usage) AllTiersResponse {
return AllTiersResponse{
CurrentTier: GetTier(premium),
Tiers: Tiers,
Usage: usage,
}
}

81
internal/db/db.go Normal file
View file

@ -0,0 +1,81 @@
package db
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
const defaultDSN = "postgres://writekit:writekit@localhost:5432/writekit?sslmode=disable"
type DB struct {
pool *pgxpool.Pool
}
func Connect(migrationsDir string) (*DB, error) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
dsn = defaultDSN
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
if err := pool.Ping(context.Background()); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
db := &DB{pool: pool}
if err := db.RunMigrations(migrationsDir); err != nil {
pool.Close()
return nil, fmt.Errorf("migrations: %w", err)
}
return db, nil
}
func (db *DB) RunMigrations(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
var files []string
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
continue
}
files = append(files, entry.Name())
}
sort.Strings(files)
for _, name := range files {
content, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
if _, err := db.pool.Exec(context.Background(), string(content)); err != nil {
return fmt.Errorf("run %s: %w", name, err)
}
}
return nil
}
func (db *DB) Close() {
db.pool.Close()
}
func (db *DB) Pool() *pgxpool.Pool {
return db.pool
}

109
internal/db/demos.go Normal file
View file

@ -0,0 +1,109 @@
package db
import (
"context"
"crypto/rand"
"encoding/hex"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
func getDemoDuration() time.Duration {
if mins := os.Getenv("DEMO_DURATION_MINUTES"); mins != "" {
if m, err := strconv.Atoi(mins); err == nil && m > 0 {
return time.Duration(m) * time.Minute
}
}
if os.Getenv("ENV") != "prod" {
return 100 * 365 * 24 * time.Hour // infinite in local/dev
}
return 15 * time.Minute
}
func (db *DB) GetDemoBySubdomain(ctx context.Context, subdomain string) (*Demo, error) {
var d Demo
err := db.pool.QueryRow(ctx,
`SELECT id, subdomain, expires_at FROM demos WHERE subdomain = $1 AND expires_at > NOW()`,
subdomain).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
if err == pgx.ErrNoRows {
return nil, nil
}
return &d, err
}
func (db *DB) GetDemoByID(ctx context.Context, id string) (*Demo, error) {
var d Demo
err := db.pool.QueryRow(ctx,
`SELECT id, subdomain, expires_at FROM demos WHERE id = $1 AND expires_at > NOW()`,
id).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
if err == pgx.ErrNoRows {
return nil, nil
}
return &d, err
}
func (db *DB) CreateDemo(ctx context.Context) (*Demo, error) {
subdomain := "demo-" + randomHex(4)
expiresAt := time.Now().Add(getDemoDuration())
var d Demo
err := db.pool.QueryRow(ctx,
`INSERT INTO demos (subdomain, expires_at) VALUES ($1, $2) RETURNING id, subdomain, expires_at`,
subdomain, expiresAt).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
if err != nil {
return nil, err
}
return &d, nil
}
type ExpiredDemo struct {
ID string
Subdomain string
}
func (db *DB) CleanupExpiredDemos(ctx context.Context) ([]ExpiredDemo, error) {
rows, err := db.pool.Query(ctx,
`DELETE FROM demos WHERE expires_at < NOW() RETURNING id, subdomain`)
if err != nil {
return nil, err
}
defer rows.Close()
var demos []ExpiredDemo
for rows.Next() {
var d ExpiredDemo
if err := rows.Scan(&d.ID, &d.Subdomain); err != nil {
return nil, err
}
demos = append(demos, d)
}
return demos, rows.Err()
}
func (db *DB) ListActiveDemos(ctx context.Context) ([]Demo, error) {
rows, err := db.pool.Query(ctx,
`SELECT id, subdomain, expires_at FROM demos WHERE expires_at > NOW()`)
if err != nil {
return nil, err
}
defer rows.Close()
var demos []Demo
for rows.Next() {
var d Demo
if err := rows.Scan(&d.ID, &d.Subdomain, &d.ExpiresAt); err != nil {
return nil, err
}
demos = append(demos, d)
}
return demos, rows.Err()
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}

View file

@ -0,0 +1,185 @@
-- Users (provider-agnostic)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
avatar_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- OAuth identities
CREATE TABLE IF NOT EXISTS user_identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
provider_email VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(provider, provider_id)
);
-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR(64) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
-- Tenants (blog instances)
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
subdomain VARCHAR(63) UNIQUE NOT NULL,
custom_domain VARCHAR(255),
premium BOOLEAN DEFAULT FALSE,
members_enabled BOOLEAN DEFAULT FALSE,
donations_enabled BOOLEAN DEFAULT FALSE,
auth_google_enabled BOOLEAN DEFAULT TRUE,
auth_github_enabled BOOLEAN DEFAULT TRUE,
auth_discord_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Demos (temporary blogs)
CREATE TABLE IF NOT EXISTS demos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subdomain VARCHAR(63) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
-- Reserved subdomains
CREATE TABLE IF NOT EXISTS reserved_subdomains (
subdomain VARCHAR(63) PRIMARY KEY,
reason VARCHAR(255)
);
-- Membership tiers
CREATE TABLE IF NOT EXISTS membership_tiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
price_cents INTEGER NOT NULL,
description TEXT,
benefits TEXT[],
lemon_variant_id VARCHAR(64),
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Subscriptions
CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
tier_id UUID REFERENCES membership_tiers(id) ON DELETE SET NULL,
tier_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
lemon_subscription_id VARCHAR(64) UNIQUE,
lemon_customer_id VARCHAR(64),
amount_cents INTEGER NOT NULL,
current_period_start TIMESTAMP WITH TIME ZONE,
current_period_end TIMESTAMP WITH TIME ZONE,
cancelled_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Donations
CREATE TABLE IF NOT EXISTS donations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
donor_email VARCHAR(255),
donor_name VARCHAR(255),
amount_cents INTEGER NOT NULL,
lemon_order_id VARCHAR(64) UNIQUE,
message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Earnings ledger
CREATE TABLE IF NOT EXISTS earnings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
source_type VARCHAR(20) NOT NULL,
source_id UUID NOT NULL,
description TEXT,
gross_cents INTEGER NOT NULL,
platform_fee_cents INTEGER NOT NULL,
processor_fee_cents INTEGER NOT NULL,
net_cents INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Balances
CREATE TABLE IF NOT EXISTS balances (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
available_cents INTEGER DEFAULT 0,
lifetime_earnings_cents INTEGER DEFAULT 0,
lifetime_paid_cents INTEGER DEFAULT 0,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Payouts
CREATE TABLE IF NOT EXISTS payouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
amount_cents INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
wise_transfer_id VARCHAR(64),
wise_quote_id VARCHAR(64),
status VARCHAR(20) NOT NULL,
failure_reason TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
-- Payout settings
CREATE TABLE IF NOT EXISTS payout_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
wise_recipient_id VARCHAR(64),
account_holder_name VARCHAR(255),
currency VARCHAR(3) DEFAULT 'USD',
payout_email VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_identities_lookup ON user_identities(provider, provider_id);
CREATE INDEX IF NOT EXISTS idx_identities_user ON user_identities(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_tenants_owner ON tenants(owner_id);
CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain);
CREATE INDEX IF NOT EXISTS idx_demos_expires ON demos(expires_at);
CREATE INDEX IF NOT EXISTS idx_demos_subdomain ON demos(subdomain);
CREATE INDEX IF NOT EXISTS idx_tiers_tenant ON membership_tiers(tenant_id) WHERE active = TRUE;
CREATE INDEX IF NOT EXISTS idx_subscriptions_tenant ON subscriptions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_lemon ON subscriptions(lemon_subscription_id);
CREATE INDEX IF NOT EXISTS idx_donations_tenant ON donations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_earnings_tenant ON earnings(tenant_id);
CREATE INDEX IF NOT EXISTS idx_payouts_tenant ON payouts(tenant_id);
-- Reserved subdomains
INSERT INTO reserved_subdomains (subdomain, reason) VALUES
('www', 'system'),
('api', 'system'),
('app', 'system'),
('admin', 'system'),
('staging', 'system'),
('demo', 'system'),
('test', 'system'),
('mail', 'system'),
('smtp', 'system'),
('ftp', 'system'),
('ssh', 'system'),
('traefik', 'system'),
('ops', 'system'),
('source', 'system'),
('ci', 'system')
ON CONFLICT (subdomain) DO NOTHING;

34
internal/db/models.go Normal file
View file

@ -0,0 +1,34 @@
package db
import "time"
type User struct {
ID string
Email string
Name string
AvatarURL string
CreatedAt time.Time
}
type Tenant struct {
ID string
OwnerID string
Subdomain string
CustomDomain string
Premium bool
MembersEnabled bool
DonationsEnabled bool
CreatedAt time.Time
}
type Session struct {
Token string
UserID string
ExpiresAt time.Time
}
type Demo struct {
ID string
Subdomain string
ExpiresAt time.Time
}

45
internal/db/sessions.go Normal file
View file

@ -0,0 +1,45 @@
package db
import (
"context"
"crypto/rand"
"encoding/hex"
"time"
"github.com/jackc/pgx/v5"
)
func (db *DB) CreateSession(ctx context.Context, userID string) (*Session, error) {
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return nil, err
}
s := &Session{
Token: hex.EncodeToString(token),
UserID: userID,
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}
_, err := db.pool.Exec(ctx,
`INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, $3)`,
s.Token, s.UserID, s.ExpiresAt)
return s, err
}
func (db *DB) ValidateSession(ctx context.Context, token string) (*Session, error) {
var s Session
err := db.pool.QueryRow(ctx,
`SELECT token, user_id, expires_at FROM sessions
WHERE token = $1 AND expires_at > NOW()`,
token).Scan(&s.Token, &s.UserID, &s.ExpiresAt)
if err == pgx.ErrNoRows {
return nil, nil
}
return &s, err
}
func (db *DB) DeleteSession(ctx context.Context, token string) error {
_, err := db.pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token)
return err
}

147
internal/db/tenants.go Normal file
View file

@ -0,0 +1,147 @@
package db
import (
"context"
"github.com/jackc/pgx/v5"
)
func (db *DB) GetTenantBySubdomain(ctx context.Context, subdomain string) (*Tenant, error) {
var t Tenant
var ownerID, customDomain *string
err := db.pool.QueryRow(ctx,
`SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
FROM tenants WHERE subdomain = $1`,
subdomain).Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if ownerID != nil {
t.OwnerID = *ownerID
}
if customDomain != nil {
t.CustomDomain = *customDomain
}
return &t, err
}
func (db *DB) GetTenantByOwner(ctx context.Context, ownerID string) (*Tenant, error) {
var t Tenant
var owner, customDomain *string
err := db.pool.QueryRow(ctx,
`SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
FROM tenants WHERE owner_id = $1`,
ownerID).Scan(&t.ID, &owner, &t.Subdomain, &customDomain, &t.Premium,
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if owner != nil {
t.OwnerID = *owner
}
if customDomain != nil {
t.CustomDomain = *customDomain
}
return &t, err
}
func (db *DB) CreateTenant(ctx context.Context, ownerID, subdomain string) (*Tenant, error) {
var t Tenant
err := db.pool.QueryRow(ctx,
`INSERT INTO tenants (owner_id, subdomain)
VALUES ($1, $2)
RETURNING id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at`,
ownerID, subdomain).Scan(&t.ID, &t.OwnerID, &t.Subdomain, &t.CustomDomain, &t.Premium,
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
return &t, err
}
func (db *DB) GetTenantByID(ctx context.Context, id string) (*Tenant, error) {
var t Tenant
var ownerID, customDomain *string
err := db.pool.QueryRow(ctx,
`SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
FROM tenants WHERE id = $1`,
id).Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if ownerID != nil {
t.OwnerID = *ownerID
}
if customDomain != nil {
t.CustomDomain = *customDomain
}
return &t, err
}
func (db *DB) IsUserTenantOwner(ctx context.Context, userID, tenantID string) (bool, error) {
var exists bool
err := db.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM tenants WHERE id = $1 AND owner_id = $2)`,
tenantID, userID).Scan(&exists)
return exists, err
}
func (db *DB) IsSubdomainAvailable(ctx context.Context, subdomain string) (bool, error) {
var exists bool
err := db.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM reserved_subdomains WHERE subdomain = $1)`,
subdomain).Scan(&exists)
if err != nil {
return false, err
}
if exists {
return false, nil
}
err = db.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM demos WHERE subdomain = $1 AND expires_at > NOW())`,
subdomain).Scan(&exists)
if err != nil {
return false, err
}
if exists {
return false, nil
}
err = db.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM tenants WHERE subdomain = $1)`,
subdomain).Scan(&exists)
if err != nil {
return false, err
}
return !exists, nil
}
func (db *DB) ListTenants(ctx context.Context) ([]Tenant, error) {
rows, err := db.pool.Query(ctx,
`SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
FROM tenants`)
if err != nil {
return nil, err
}
defer rows.Close()
var tenants []Tenant
for rows.Next() {
var t Tenant
var ownerID, customDomain *string
if err := rows.Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
&t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt); err != nil {
return nil, err
}
if ownerID != nil {
t.OwnerID = *ownerID
}
if customDomain != nil {
t.CustomDomain = *customDomain
}
tenants = append(tenants, t)
}
return tenants, rows.Err()
}

62
internal/db/users.go Normal file
View file

@ -0,0 +1,62 @@
package db
import (
"context"
"github.com/jackc/pgx/v5"
)
func (db *DB) GetUserByID(ctx context.Context, id string) (*User, error) {
var u User
err := db.pool.QueryRow(ctx,
`SELECT id, email, name, avatar_url, created_at FROM users WHERE id = $1`,
id).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
return &u, err
}
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
var u User
err := db.pool.QueryRow(ctx,
`SELECT id, email, name, avatar_url, created_at FROM users WHERE email = $1`,
email).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
return &u, err
}
func (db *DB) GetUserByIdentity(ctx context.Context, provider, providerID string) (*User, error) {
var u User
err := db.pool.QueryRow(ctx,
`SELECT u.id, u.email, u.name, u.avatar_url, u.created_at
FROM users u
JOIN user_identities i ON i.user_id = u.id
WHERE i.provider = $1 AND i.provider_id = $2`,
provider, providerID).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
return &u, err
}
func (db *DB) CreateUser(ctx context.Context, email, name, avatarURL string) (*User, error) {
var u User
err := db.pool.QueryRow(ctx,
`INSERT INTO users (email, name, avatar_url)
VALUES ($1, $2, $3)
RETURNING id, email, name, avatar_url, created_at`,
email, name, avatarURL).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
return &u, err
}
func (db *DB) AddUserIdentity(ctx context.Context, userID, provider, providerID, providerEmail string) error {
_, err := db.pool.Exec(ctx,
`INSERT INTO user_identities (user_id, provider, provider_id, provider_email)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, provider_id) DO NOTHING`,
userID, provider, providerID, providerEmail)
return err
}

View file

@ -0,0 +1,77 @@
package imaginary
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
)
type Client struct {
baseURL string
http *http.Client
}
func New(baseURL string) *Client {
return &Client{
baseURL: baseURL,
http: &http.Client{},
}
}
type ProcessOptions struct {
Width int
Height int
Quality int
Type string
}
func (c *Client) Process(src io.Reader, filename string, opts ProcessOptions) ([]byte, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return nil, err
}
if _, err := io.Copy(part, src); err != nil {
return nil, err
}
writer.Close()
if opts.Width == 0 {
opts.Width = 2000
}
if opts.Height == 0 {
opts.Height = 2000
}
if opts.Quality == 0 {
opts.Quality = 80
}
if opts.Type == "" {
opts.Type = "webp"
}
url := fmt.Sprintf("%s/resize?width=%d&height=%d&quality=%d&type=%s",
c.baseURL, opts.Width, opts.Height, opts.Quality, opts.Type)
req, err := http.NewRequest("POST", url, &body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("imaginary error %d: %s", resp.StatusCode, string(errBody))
}
return io.ReadAll(resp.Body)
}

View file

@ -0,0 +1,72 @@
package markdown
import (
"bytes"
"sync"
"github.com/yuin/goldmark"
emoji "github.com/yuin/goldmark-emoji"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
var (
renderers = make(map[string]goldmark.Markdown)
renderersMu sync.RWMutex
)
func getRenderer(codeTheme string) goldmark.Markdown {
if codeTheme == "" {
codeTheme = "github"
}
renderersMu.RLock()
if md, ok := renderers[codeTheme]; ok {
renderersMu.RUnlock()
return md
}
renderersMu.RUnlock()
renderersMu.Lock()
defer renderersMu.Unlock()
if md, ok := renderers[codeTheme]; ok {
return md
}
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Typographer,
emoji.Emoji,
highlighting.NewHighlighting(
highlighting.WithStyle(codeTheme),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
html.WithUnsafe(),
),
)
renderers[codeTheme] = md
return md
}
func Render(source string) (string, error) {
return RenderWithTheme(source, "github")
}
func RenderWithTheme(source, codeTheme string) (string, error) {
md := getRenderer(codeTheme)
var buf bytes.Buffer
if err := md.Convert([]byte(source), &buf); err != nil {
return "", err
}
return buf.String(), nil
}

152
internal/markdown/themes.go Normal file
View file

@ -0,0 +1,152 @@
package markdown
import (
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/styles"
)
var chromaToHljs = map[chroma.TokenType]string{
chroma.Keyword: "hljs-keyword",
chroma.KeywordConstant: "hljs-keyword",
chroma.KeywordDeclaration: "hljs-keyword",
chroma.KeywordNamespace: "hljs-keyword",
chroma.KeywordPseudo: "hljs-keyword",
chroma.KeywordReserved: "hljs-keyword",
chroma.KeywordType: "hljs-type",
chroma.Name: "hljs-variable",
chroma.NameBuiltin: "hljs-built_in",
chroma.NameBuiltinPseudo: "hljs-built_in",
chroma.NameClass: "hljs-title class_",
chroma.NameConstant: "hljs-variable constant_",
chroma.NameDecorator: "hljs-meta",
chroma.NameEntity: "hljs-name",
chroma.NameException: "hljs-title class_",
chroma.NameFunction: "hljs-title function_",
chroma.NameFunctionMagic: "hljs-title function_",
chroma.NameLabel: "hljs-symbol",
chroma.NameNamespace: "hljs-title class_",
chroma.NameOther: "hljs-variable",
chroma.NameProperty: "hljs-property",
chroma.NameTag: "hljs-tag",
chroma.NameVariable: "hljs-variable",
chroma.NameVariableClass: "hljs-variable",
chroma.NameVariableGlobal: "hljs-variable",
chroma.NameVariableInstance: "hljs-variable",
chroma.NameVariableMagic: "hljs-variable",
chroma.NameAttribute: "hljs-attr",
chroma.Literal: "hljs-literal",
chroma.LiteralDate: "hljs-number",
chroma.String: "hljs-string",
chroma.StringAffix: "hljs-string",
chroma.StringBacktick: "hljs-string",
chroma.StringChar: "hljs-string",
chroma.StringDelimiter: "hljs-string",
chroma.StringDoc: "hljs-string",
chroma.StringDouble: "hljs-string",
chroma.StringEscape: "hljs-char escape_",
chroma.StringHeredoc: "hljs-string",
chroma.StringInterpol: "hljs-subst",
chroma.StringOther: "hljs-string",
chroma.StringRegex: "hljs-regexp",
chroma.StringSingle: "hljs-string",
chroma.StringSymbol: "hljs-symbol",
chroma.Number: "hljs-number",
chroma.NumberBin: "hljs-number",
chroma.NumberFloat: "hljs-number",
chroma.NumberHex: "hljs-number",
chroma.NumberInteger: "hljs-number",
chroma.NumberIntegerLong: "hljs-number",
chroma.NumberOct: "hljs-number",
chroma.Operator: "hljs-operator",
chroma.OperatorWord: "hljs-keyword",
chroma.Punctuation: "hljs-punctuation",
chroma.Comment: "hljs-comment",
chroma.CommentHashbang: "hljs-meta",
chroma.CommentMultiline: "hljs-comment",
chroma.CommentPreproc: "hljs-meta",
chroma.CommentPreprocFile: "hljs-meta",
chroma.CommentSingle: "hljs-comment",
chroma.CommentSpecial: "hljs-doctag",
chroma.Generic: "hljs-code",
chroma.GenericDeleted: "hljs-deletion",
chroma.GenericEmph: "hljs-emphasis",
chroma.GenericError: "hljs-strong",
chroma.GenericHeading: "hljs-section",
chroma.GenericInserted: "hljs-addition",
chroma.GenericOutput: "hljs-code",
chroma.GenericPrompt: "hljs-meta prompt_",
chroma.GenericStrong: "hljs-strong",
chroma.GenericSubheading: "hljs-section",
chroma.GenericTraceback: "hljs-code",
chroma.GenericUnderline: "hljs-code",
chroma.Text: "",
chroma.TextWhitespace: "",
}
func GenerateHljsCSS(themeName string) (string, error) {
style := styles.Get(themeName)
if style == nil {
style = styles.Get("github")
}
var css strings.Builder
css.WriteString("/* Auto-generated from Chroma theme: " + themeName + " */\n")
bg := style.Get(chroma.Background)
if bg.Background.IsSet() {
css.WriteString(".prose pre { background: " + bg.Background.String() + "; }\n")
}
if bg.Colour.IsSet() {
css.WriteString(".prose pre code { color: " + bg.Colour.String() + "; }\n")
}
seen := make(map[string]bool)
for chromaToken, hljsClass := range chromaToHljs {
if hljsClass == "" {
continue
}
entry := style.Get(chromaToken)
if !entry.Colour.IsSet() && entry.Bold != chroma.Yes && entry.Italic != chroma.Yes {
continue
}
if seen[hljsClass] {
continue
}
seen[hljsClass] = true
selector := "." + strings.ReplaceAll(hljsClass, " ", ".")
css.WriteString(selector + " {")
if entry.Colour.IsSet() {
css.WriteString(" color: " + entry.Colour.String() + ";")
}
if entry.Bold == chroma.Yes {
css.WriteString(" font-weight: bold;")
}
if entry.Italic == chroma.Yes {
css.WriteString(" font-style: italic;")
}
css.WriteString(" }\n")
}
return css.String(), nil
}
func ListThemes() []string {
return styles.Names()
}

185
internal/og/og.go Normal file
View file

@ -0,0 +1,185 @@
package og
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/gofont/gobold"
)
const (
imgWidth = 1200
imgHeight = 630
)
var (
regularFont *truetype.Font
boldFont *truetype.Font
)
func init() {
var err error
regularFont, err = freetype.ParseFont(goregular.TTF)
if err != nil {
panic(err)
}
boldFont, err = freetype.ParseFont(gobold.TTF)
if err != nil {
panic(err)
}
}
func parseHexColor(hex string) color.RGBA {
if len(hex) == 0 {
return color.RGBA{16, 185, 129, 255} // #10b981 emerald
}
if hex[0] == '#' {
hex = hex[1:]
}
if len(hex) != 6 {
return color.RGBA{16, 185, 129, 255} // #10b981 emerald
}
var r, g, b uint8
for i, c := range hex {
var v uint8
switch {
case c >= '0' && c <= '9':
v = uint8(c - '0')
case c >= 'a' && c <= 'f':
v = uint8(c - 'a' + 10)
case c >= 'A' && c <= 'F':
v = uint8(c - 'A' + 10)
}
switch i {
case 0:
r = v << 4
case 1:
r |= v
case 2:
g = v << 4
case 3:
g |= v
case 4:
b = v << 4
case 5:
b |= v
}
}
return color.RGBA{r, g, b, 255}
}
func Generate(title, siteName, accentColor string) ([]byte, error) {
accent := parseHexColor(accentColor)
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
for y := 0; y < imgHeight; y++ {
ratio := float64(y) / float64(imgHeight)
r := uint8(float64(accent.R) * (1 - ratio*0.3))
g := uint8(float64(accent.G) * (1 - ratio*0.3))
b := uint8(float64(accent.B) * (1 - ratio*0.3))
for x := 0; x < imgWidth; x++ {
img.Set(x, y, color.RGBA{r, g, b, 255})
}
}
c := freetype.NewContext()
c.SetDPI(72)
c.SetClip(img.Bounds())
c.SetDst(img)
c.SetSrc(image.White)
c.SetHinting(font.HintingFull)
titleSize := 64.0
if len(title) > 50 {
titleSize = 48.0
}
if len(title) > 80 {
titleSize = 40.0
}
c.SetFont(boldFont)
c.SetFontSize(titleSize)
wrapped := wrapText(title, 28)
y := 200
for _, line := range wrapped {
pt := freetype.Pt(80, y)
c.DrawString(line, pt)
y += int(titleSize * 1.4)
}
c.SetFont(regularFont)
c.SetFontSize(28)
pt := freetype.Pt(80, imgHeight-80)
c.DrawString(siteName, pt)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func wrapText(text string, maxChars int) []string {
if len(text) <= maxChars {
return []string{text}
}
var lines []string
words := splitWords(text)
var current string
for _, word := range words {
if current == "" {
current = word
} else if len(current)+1+len(word) <= maxChars {
current += " " + word
} else {
lines = append(lines, current)
current = word
}
}
if current != "" {
lines = append(lines, current)
}
if len(lines) > 4 {
lines = lines[:4]
if len(lines[3]) > 3 {
lines[3] = lines[3][:len(lines[3])-3] + "..."
}
}
return lines
}
func splitWords(s string) []string {
var words []string
var current string
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' {
if current != "" {
words = append(words, current)
current = ""
}
} else {
current += string(r)
}
}
if current != "" {
words = append(words, current)
}
return words
}
var _ = draw.Draw

208
internal/server/api.go Normal file
View file

@ -0,0 +1,208 @@
package server
import (
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/writekitapp/writekit/internal/auth"
"github.com/writekitapp/writekit/internal/tenant"
)
func (s *Server) publicAPIRoutes() chi.Router {
r := chi.NewRouter()
r.Use(s.apiKeyMiddleware)
r.Use(s.apiRateLimitMiddleware(s.rateLimiter))
r.Get("/posts", s.apiListPosts)
r.Post("/posts", s.apiCreatePost)
r.Get("/posts/{slug}", s.apiGetPost)
r.Put("/posts/{slug}", s.apiUpdatePost)
r.Delete("/posts/{slug}", s.apiDeletePost)
r.Get("/settings", s.apiGetSettings)
return r
}
func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
jsonError(w, http.StatusUnauthorized, "unauthorized")
return
}
key := extractAPIKey(r)
if key != "" {
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
valid, err := q.ValidateAPIKey(r.Context(), key)
if err != nil {
jsonError(w, http.StatusInternalServerError, "validation error")
return
}
if valid {
next.ServeHTTP(w, r)
return
}
jsonError(w, http.StatusUnauthorized, "invalid API key")
return
}
if GetDemoInfo(r).IsDemo {
next.ServeHTTP(w, r)
return
}
userID := auth.GetUserID(r)
if userID == "" {
jsonError(w, http.StatusUnauthorized, "API key required")
return
}
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
if err != nil || !isOwner {
jsonError(w, http.StatusUnauthorized, "API key required")
return
}
next.ServeHTTP(w, r)
})
}
func extractAPIKey(r *http.Request) string {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return r.URL.Query().Get("api_key")
}
type paginatedPostsResponse struct {
Posts []postResponse `json:"posts"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func (s *Server) apiListPosts(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
tag := r.URL.Query().Get("tag")
includeContent := r.URL.Query().Get("include") == "content"
q := tenant.NewQueries(db)
result, err := q.ListPostsPaginated(r.Context(), tenant.ListPostsOptions{
Limit: limit,
Offset: offset,
Tag: tag,
})
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list posts")
return
}
posts := make([]postResponse, len(result.Posts))
for i, p := range result.Posts {
posts[i] = postToResponse(&p, includeContent)
}
jsonResponse(w, http.StatusOK, paginatedPostsResponse{
Posts: posts,
Total: result.Total,
Limit: limit,
Offset: offset,
})
}
func (s *Server) apiGetPost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
post, err := q.GetPost(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post")
return
}
if post == nil {
jsonError(w, http.StatusNotFound, "post not found")
return
}
jsonResponse(w, http.StatusOK, postToResponse(post, true))
}
func (s *Server) apiCreatePost(w http.ResponseWriter, r *http.Request) {
s.createPost(w, r)
}
func (s *Server) apiUpdatePost(w http.ResponseWriter, r *http.Request) {
s.updatePost(w, r)
}
func (s *Server) apiDeletePost(w http.ResponseWriter, r *http.Request) {
s.deletePost(w, r)
}
var publicSettingsKeys = []string{
"site_name",
"site_description",
"author_name",
"author_role",
"author_bio",
"author_photo",
"twitter_handle",
"github_handle",
"linkedin_handle",
"email",
"accent_color",
"font",
}
func (s *Server) apiGetSettings(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
allSettings, err := q.GetSettings(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get settings")
return
}
result := make(map[string]string)
for _, key := range publicSettingsKeys {
if val, ok := allSettings[key]; ok {
result[key] = val
}
}
jsonResponse(w, http.StatusOK, result)
}

703
internal/server/blog.go Normal file
View file

@ -0,0 +1,703 @@
package server
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/writekitapp/writekit/internal/auth"
"github.com/writekitapp/writekit/internal/build/assets"
"github.com/writekitapp/writekit/internal/build/templates"
"github.com/writekitapp/writekit/internal/config"
"github.com/writekitapp/writekit/internal/markdown"
"github.com/writekitapp/writekit/internal/tenant"
"github.com/writekitapp/writekit/studio"
)
func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain string) {
var tenantID string
var demoInfo DemoInfo
tenantID, ok := s.tenantCache.Get(subdomain)
if !ok {
t, err := s.database.GetTenantBySubdomain(r.Context(), subdomain)
if err != nil || t == nil {
d, err := s.database.GetDemoBySubdomain(r.Context(), subdomain)
if err != nil || d == nil {
s.notFound(w, r)
return
}
tenantID = d.ID
demoInfo = DemoInfo{IsDemo: true, ExpiresAt: d.ExpiresAt}
s.tenantPool.MarkAsDemo(tenantID)
s.ensureDemoSeeded(tenantID)
} else {
tenantID = t.ID
}
s.tenantCache.Set(subdomain, tenantID)
} else {
d, _ := s.database.GetDemoBySubdomain(r.Context(), subdomain)
if d != nil {
demoInfo = DemoInfo{IsDemo: true, ExpiresAt: d.ExpiresAt}
s.tenantPool.MarkAsDemo(tenantID)
}
}
ctx := context.WithValue(r.Context(), tenantIDKey, tenantID)
ctx = context.WithValue(ctx, demoInfoKey, demoInfo)
r = r.WithContext(ctx)
mux := chi.NewRouter()
mux.Get("/", s.blogHome)
mux.Get("/posts", s.blogList)
mux.Get("/posts/{slug}", s.blogPost)
mux.Handle("/static/*", http.StripPrefix("/static/", assets.Handler()))
mux.Route("/api/studio", func(r chi.Router) {
r.Use(demoAwareSessionMiddleware(s.database))
r.Use(s.ownerMiddleware)
r.Mount("/", s.studioRoutes())
})
mux.Mount("/api/v1", s.publicAPIRoutes())
mux.Mount("/api/reader", s.readerRoutes())
mux.Get("/studio", s.serveStudio)
mux.Get("/studio/*", s.serveStudio)
mux.Get("/sitemap.xml", s.sitemap)
mux.Get("/robots.txt", s.robots)
mux.ServeHTTP(w, r)
}
func (s *Server) blogHome(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
slog.Error("blogHome: get tenant pool", "error", err, "tenantID", tenantID)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
q := tenant.NewQueries(db)
s.recordPageView(q, r, "/", "")
if html, etag, err := q.GetPage(r.Context(), "/"); err == nil && html != nil {
s.servePreRendered(w, r, html, etag, "public, max-age=300")
return
}
posts, err := q.ListPosts(r.Context(), false)
if err != nil {
slog.Error("blogHome: list posts", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
settings, _ := q.GetSettings(r.Context())
siteName := getSettingOr(settings, "site_name", "My Blog")
siteDesc := getSettingOr(settings, "site_description", "")
baseURL := getBaseURL(r.Host)
showBadge := true
if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
tierInfo := config.GetTierInfo(t.Premium)
showBadge = tierInfo.Config.BadgeRequired
}
postSummaries := make([]templates.PostSummary, 0, len(posts))
for _, p := range posts {
if len(postSummaries) >= 10 {
break
}
postSummaries = append(postSummaries, templates.PostSummary{
Slug: p.Slug,
Title: p.Title,
Description: p.Description,
Date: timeOrZero(p.PublishedAt),
})
}
data := templates.HomeData{
PageData: templates.PageData{
Title: siteName,
Description: siteDesc,
CanonicalURL: baseURL + "/",
OGType: "website",
SiteName: siteName,
Year: time.Now().Year(),
Settings: settingsToMap(settings),
NoIndex: GetDemoInfo(r).IsDemo,
ShowBadge: showBadge,
},
Posts: postSummaries,
HasMore: len(posts) > 10,
}
html, err := templates.RenderHome(data)
if err != nil {
slog.Error("blogHome: render template", "error", err)
http.Error(w, "render error", http.StatusInternalServerError)
return
}
s.servePreRendered(w, r, html, computeETag(html), "public, max-age=300")
}
func (s *Server) blogList(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
q := tenant.NewQueries(db)
s.recordPageView(q, r, "/posts", "")
if html, etag, err := q.GetPage(r.Context(), "/posts"); err == nil && html != nil {
s.servePreRendered(w, r, html, etag, "public, max-age=300")
return
}
posts, err := q.ListPosts(r.Context(), false)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
settings, _ := q.GetSettings(r.Context())
siteName := getSettingOr(settings, "site_name", "My Blog")
baseURL := getBaseURL(r.Host)
showBadge := true
if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
tierInfo := config.GetTierInfo(t.Premium)
showBadge = tierInfo.Config.BadgeRequired
}
postSummaries := make([]templates.PostSummary, len(posts))
for i, p := range posts {
postSummaries[i] = templates.PostSummary{
Slug: p.Slug,
Title: p.Title,
Description: p.Description,
Date: timeOrZero(p.PublishedAt),
}
}
data := templates.BlogData{
PageData: templates.PageData{
Title: "Posts - " + siteName,
Description: "All posts",
CanonicalURL: baseURL + "/posts",
OGType: "website",
SiteName: siteName,
Year: time.Now().Year(),
Settings: settingsToMap(settings),
NoIndex: GetDemoInfo(r).IsDemo,
ShowBadge: showBadge,
},
Posts: postSummaries,
}
html, err := templates.RenderBlog(data)
if err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
return
}
s.servePreRendered(w, r, html, computeETag(html), "public, max-age=300")
}
func (s *Server) blogPost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
isPreview := r.URL.Query().Get("preview") == "true"
db, err := s.tenantPool.Get(tenantID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
q := tenant.NewQueries(db)
if isPreview && !s.canPreview(r, tenantID) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !isPreview {
path := "/posts/" + slug
s.recordPageView(q, r, path, slug)
if html, etag, err := q.GetPage(r.Context(), path); err == nil && html != nil {
s.servePreRendered(w, r, html, etag, "public, max-age=3600")
return
}
}
post, err := q.GetPost(r.Context(), slug)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if post == nil {
aliasPost, _ := q.GetPostByAlias(r.Context(), slug)
if aliasPost != nil && aliasPost.IsPublished {
http.Redirect(w, r, "/posts/"+aliasPost.Slug, http.StatusMovedPermanently)
return
}
http.NotFound(w, r)
return
}
if !post.IsPublished && !isPreview {
http.NotFound(w, r)
return
}
title := post.Title
description := post.Description
contentMD := post.ContentMD
tags := post.Tags
coverImage := post.CoverImage
if isPreview {
if draft, _ := q.GetDraft(r.Context(), post.ID); draft != nil {
title = draft.Title
description = draft.Description
contentMD = draft.ContentMD
tags = draft.Tags
coverImage = draft.CoverImage
}
}
settings, _ := q.GetSettings(r.Context())
siteName := getSettingOr(settings, "site_name", "My Blog")
baseURL := getBaseURL(r.Host)
codeTheme := getSettingOr(settings, "code_theme", "github")
showBadge := true
if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
tierInfo := config.GetTierInfo(t.Premium)
showBadge = tierInfo.Config.BadgeRequired
}
contentHTML := ""
if contentMD != "" {
contentHTML, _ = markdown.RenderWithTheme(contentMD, codeTheme)
}
interactionConfig := q.GetInteractionConfig(r.Context())
structuredData := buildArticleSchema(post, siteName, baseURL)
data := templates.PostData{
PageData: templates.PageData{
Title: title + " - " + siteName,
Description: description,
CanonicalURL: baseURL + "/posts/" + post.Slug,
OGType: "article",
OGImage: coverImage,
SiteName: siteName,
Year: time.Now().Year(),
StructuredData: template.JS(structuredData),
Settings: settingsToMap(settings),
NoIndex: GetDemoInfo(r).IsDemo || isPreview,
ShowBadge: showBadge,
},
Post: templates.PostDetail{
Slug: post.Slug,
Title: title,
Description: description,
CoverImage: coverImage,
Date: timeOrZero(post.PublishedAt),
Tags: tags,
},
ContentHTML: template.HTML(contentHTML),
InteractionConfig: interactionConfig,
}
html, err := templates.RenderPost(data)
if err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
return
}
if isPreview {
previewScript := `<style>
.preview-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
z-index: 99999;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
}
@media(min-width:640px) {
.preview-banner {
bottom: auto;
top: 0;
left: 50%;
right: auto;
transform: translateX(-50%);
border-radius: 0 0 8px 8px;
padding: 8px 20px;
}
}
.preview-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
letter-spacing: 0.02em;
}
.preview-badge svg {
width: 14px;
height: 14px;
}
.preview-status {
opacity: 0.9;
font-size: 12px;
}
.preview-link {
background: rgba(255,255,255,0.2);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
text-decoration: none;
font-weight: 500;
font-size: 12px;
transition: background 0.15s;
}
.preview-link:hover {
background: rgba(255,255,255,0.3);
}
.preview-rebuild {
position: fixed;
top: 16px;
right: 16px;
background: #18181b;
color: #fafafa;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-family: system-ui, sans-serif;
z-index: 99998;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.preview-rebuild.visible { opacity: 1; }
</style>
<div class="preview-banner">
<span class="preview-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
Preview Mode
</span>
<span class="preview-status">Viewing as author</span>
<a class="preview-link" href="/studio/posts/` + post.Slug + `/edit">Back to Editor</a>
</div>
<div class="preview-rebuild" id="preview-rebuild">Rebuilding...</div>
<script>
(function() {
var channel = new BroadcastChannel('writekit-preview');
var slug = '` + post.Slug + `';
var rebuild = document.getElementById('preview-rebuild');
channel.onmessage = function(e) {
if (e.data.slug !== slug) return;
if (e.data.type === 'rebuilding') {
rebuild.classList.add('visible');
return;
}
if (e.data.type === 'content-update') {
var content = document.querySelector('.post-content');
if (content) content.innerHTML = e.data.html;
var title = document.querySelector('h1');
if (title) title.textContent = e.data.title;
var desc = document.querySelector('meta[name="description"]');
if (desc) desc.content = e.data.description;
rebuild.classList.remove('visible');
}
};
})();
</script>`
html = bytes.Replace(html, []byte("</body>"), []byte(previewScript+"</body>"), 1)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(html)
return
}
s.servePreRendered(w, r, html, computeETag(html), "public, max-age=3600")
}
func (s *Server) canPreview(r *http.Request, tenantID string) bool {
if GetDemoInfo(r).IsDemo {
return true
}
userID := auth.GetUserID(r)
if userID == "" {
return false
}
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
if err != nil {
return false
}
return isOwner
}
func (s *Server) serveStudio(w http.ResponseWriter, r *http.Request) {
if viteURL := os.Getenv("VITE_URL"); viteURL != "" && os.Getenv("ENV") == "local" {
target, err := url.Parse(viteURL)
if err != nil {
slog.Error("invalid VITE_URL", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Director = func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
}
proxy.ServeHTTP(w, r)
return
}
path := chi.URLParam(r, "*")
if path == "" {
path = "index.html"
}
data, err := studio.Read(path)
if err != nil {
data, _ = studio.Read("index.html")
}
contentType := "text/html; charset=utf-8"
if len(path) > 3 {
switch path[len(path)-3:] {
case ".js":
contentType = "application/javascript"
case "css":
contentType = "text/css"
}
}
if contentType == "text/html; charset=utf-8" {
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
data = s.injectDemoBanner(data, demoInfo.ExpiresAt)
}
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if contentType == "text/html; charset=utf-8" {
w.Header().Set("Cache-Control", "no-cache")
}
w.Write(data)
}
func (s *Server) sitemap(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
q := tenant.NewQueries(db)
posts, _ := q.ListPosts(r.Context(), false)
baseURL := getBaseURL(r.Host)
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>` + baseURL + `/</loc></url>
`))
for _, p := range posts {
lastmod := p.ModifiedAt.Format("2006-01-02")
if p.UpdatedAt != nil {
lastmod = p.UpdatedAt.Format("2006-01-02")
}
w.Write([]byte(fmt.Sprintf(" <url><loc>%s/posts/%s</loc><lastmod>%s</lastmod></url>\n",
baseURL, p.Slug, lastmod)))
}
w.Write([]byte("</urlset>"))
}
func (s *Server) robots(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
if GetDemoInfo(r).IsDemo {
w.Write([]byte("User-agent: *\nDisallow: /\n"))
return
}
baseURL := getBaseURL(r.Host)
fmt.Fprintf(w, "User-agent: *\nAllow: /\n\nSitemap: %s/sitemap.xml\n", baseURL)
}
func (s *Server) ownerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
demoInfo := GetDemoInfo(r)
if demoInfo.IsDemo {
next.ServeHTTP(w, r)
return
}
userID := auth.GetUserID(r)
if userID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !isOwner {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func getSettingOr(settings tenant.Settings, key, fallback string) string {
if v, ok := settings[key]; ok && v != "" {
return v
}
return fallback
}
func settingsToMap(settings tenant.Settings) map[string]any {
m := make(map[string]any)
for k, v := range settings {
m[k] = v
}
return m
}
func getBaseURL(host string) string {
scheme := "https"
if env := os.Getenv("ENV"); env != "prod" {
scheme = "http"
}
return fmt.Sprintf("%s://%s", scheme, host)
}
func computeETag(data []byte) string {
hash := md5.Sum(data)
return `"` + hex.EncodeToString(hash[:]) + `"`
}
func (s *Server) servePreRendered(w http.ResponseWriter, r *http.Request, html []byte, etag, cacheControl string) {
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
html = s.injectDemoBanner(html, demoInfo.ExpiresAt)
etag = computeETag(html)
}
if match := r.Header.Get("If-None-Match"); match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", cacheControl)
w.Header().Set("ETag", etag)
w.Write(html)
}
func buildArticleSchema(post *tenant.Post, siteName, baseURL string) string {
publishedAt := timeOrZero(post.PublishedAt)
modifiedAt := publishedAt
if post.UpdatedAt != nil {
modifiedAt = *post.UpdatedAt
}
schema := map[string]any{
"@context": "https://schema.org",
"@type": "Article",
"headline": post.Title,
"datePublished": publishedAt.Format(time.RFC3339),
"dateModified": modifiedAt.Format(time.RFC3339),
"author": map[string]any{
"@type": "Person",
"name": siteName,
},
"publisher": map[string]any{
"@type": "Organization",
"name": siteName,
},
"mainEntityOfPage": map[string]any{
"@type": "WebPage",
"@id": baseURL + "/posts/" + post.Slug,
},
}
if post.Description != "" {
schema["description"] = post.Description
}
b, _ := json.Marshal(schema)
return string(b)
}
func (s *Server) recordPageView(q *tenant.Queries, r *http.Request, path, postSlug string) {
referrer := r.Header.Get("Referer")
userAgent := r.Header.Get("User-Agent")
go func() {
q.RecordPageView(context.Background(), path, postSlug, referrer, userAgent)
}()
}
func timeOrZero(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}

176
internal/server/build.go Normal file
View file

@ -0,0 +1,176 @@
package server
import (
"context"
"database/sql"
"html/template"
"log/slog"
"sync"
"time"
"github.com/writekitapp/writekit/internal/build/templates"
"github.com/writekitapp/writekit/internal/markdown"
"github.com/writekitapp/writekit/internal/tenant"
)
type renderedPage struct {
path string
html []byte
etag string
}
func (s *Server) rebuildSite(ctx context.Context, tenantID string, db *sql.DB, host string) {
q := tenant.NewQueries(db)
settings, err := q.GetSettings(ctx)
if err != nil {
slog.Error("rebuildSite: get settings", "error", err, "tenantID", tenantID)
return
}
posts, err := q.ListPosts(ctx, false)
if err != nil {
slog.Error("rebuildSite: list posts", "error", err, "tenantID", tenantID)
return
}
baseURL := getBaseURL(host)
siteName := getSettingOr(settings, "site_name", "My Blog")
siteDesc := getSettingOr(settings, "site_description", "")
codeTheme := getSettingOr(settings, "code_theme", "github")
fontKey := getSettingOr(settings, "font", "system")
isDemo := getSettingOr(settings, "is_demo", "") == "true"
pageData := templates.PageData{
SiteName: siteName,
Year: time.Now().Year(),
FontURL: templates.GetFontURL(fontKey),
FontFamily: templates.GetFontFamily(fontKey),
Settings: settingsToMap(settings),
NoIndex: isDemo,
}
var pages []renderedPage
var mu sync.Mutex
var wg sync.WaitGroup
addPage := func(path string, html []byte) {
mu.Lock()
pages = append(pages, renderedPage{path, html, computeETag(html)})
mu.Unlock()
}
postSummaries := make([]templates.PostSummary, len(posts))
for i, p := range posts {
postSummaries[i] = templates.PostSummary{
Slug: p.Slug,
Title: p.Title,
Description: p.Description,
Date: timeOrZero(p.PublishedAt),
}
}
wg.Add(1)
go func() {
defer wg.Done()
homePosts := postSummaries
if len(homePosts) > 10 {
homePosts = homePosts[:10]
}
data := templates.HomeData{
PageData: pageData,
Posts: homePosts,
HasMore: len(postSummaries) > 10,
}
data.Title = siteName
data.Description = siteDesc
data.CanonicalURL = baseURL + "/"
data.OGType = "website"
html, err := templates.RenderHome(data)
if err != nil {
slog.Error("rebuildSite: render home", "error", err)
return
}
addPage("/", html)
}()
wg.Add(1)
go func() {
defer wg.Done()
data := templates.BlogData{
PageData: pageData,
Posts: postSummaries,
}
data.Title = "Posts - " + siteName
data.Description = "All posts"
data.CanonicalURL = baseURL + "/posts"
data.OGType = "website"
html, err := templates.RenderBlog(data)
if err != nil {
slog.Error("rebuildSite: render blog", "error", err)
return
}
addPage("/posts", html)
}()
for _, p := range posts {
wg.Add(1)
go func(post tenant.Post) {
defer wg.Done()
contentHTML := post.ContentHTML
if contentHTML == "" && post.ContentMD != "" {
contentHTML, _ = markdown.RenderWithTheme(post.ContentMD, codeTheme)
}
interactionConfig := q.GetInteractionConfig(ctx)
structuredData := buildArticleSchema(&post, siteName, baseURL)
data := templates.PostData{
PageData: pageData,
Post: templates.PostDetail{
Slug: post.Slug,
Title: post.Title,
Description: post.Description,
Date: timeOrZero(post.PublishedAt),
Tags: post.Tags,
},
ContentHTML: template.HTML(contentHTML),
InteractionConfig: interactionConfig,
}
data.Title = post.Title + " - " + siteName
data.Description = post.Description
data.CanonicalURL = baseURL + "/posts/" + post.Slug
data.OGType = "article"
data.StructuredData = template.JS(structuredData)
html, err := templates.RenderPost(data)
if err != nil {
slog.Error("rebuildSite: render post", "error", err, "slug", post.Slug)
return
}
addPage("/posts/"+post.Slug, html)
}(p)
}
wg.Wait()
for _, p := range pages {
if err := q.SetPage(ctx, p.path, p.html, p.etag); err != nil {
slog.Error("rebuildSite: save page", "error", err, "path", p.path)
}
}
if s.cloudflare.IsConfigured() {
urls := make([]string, len(pages))
for i, p := range pages {
urls[i] = baseURL + p.path
}
if err := s.cloudflare.PurgeURLs(ctx, urls); err != nil {
slog.Error("rebuildSite: purge cache", "error", err)
}
}
slog.Info("rebuildSite: complete", "tenantID", tenantID, "pages", len(pages))
}

208
internal/server/demo.go Normal file
View file

@ -0,0 +1,208 @@
package server
import (
"bytes"
"math/rand"
"net/http"
"os"
"strconv"
"time"
"github.com/writekitapp/writekit/internal/auth"
"github.com/writekitapp/writekit/internal/db"
"github.com/writekitapp/writekit/internal/tenant"
)
type ctxKey string
const (
tenantIDKey ctxKey = "tenantID"
demoInfoKey ctxKey = "demoInfo"
)
type DemoInfo struct {
IsDemo bool
ExpiresAt time.Time
}
func GetDemoInfo(r *http.Request) DemoInfo {
if info, ok := r.Context().Value(demoInfoKey).(DemoInfo); ok {
return info
}
return DemoInfo{}
}
func demoAwareSessionMiddleware(database *db.DB) func(http.Handler) http.Handler {
sessionMW := auth.SessionMiddleware(database)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
next.ServeHTTP(w, r)
return
}
sessionMW(next).ServeHTTP(w, r)
})
}
}
func (s *Server) injectDemoBanner(html []byte, expiresAt time.Time) []byte {
scheme := "https"
if os.Getenv("ENV") != "prod" {
scheme = "http"
}
redirectURL := scheme + "://" + s.domain
banner := demoBannerHTML(expiresAt, redirectURL)
return bytes.Replace(html, []byte("</body>"), append([]byte(banner), []byte("</body>")...), 1)
}
func demoBannerHTML(expiresAt time.Time, redirectURL string) string {
expiresUnix := expiresAt.Unix()
return `<div id="demo-banner"><div class="demo-inner"><span class="demo-timer"></span><a class="demo-cta" target="_blank" href="/studio">Open Studio</a></div></div>
<style>
#demo-banner{position:fixed;bottom:0;left:0;right:0;background:rgba(220,38,38,0.95);color:#fff;font-family:system-ui,-apple-system,sans-serif;font-size:13px;z-index:99999;backdrop-filter:blur(8px);padding:10px 16px}
@media(min-width:640px){#demo-banner{bottom:auto;top:0;left:auto;right:16px;width:auto;padding:8px 16px}}
.demo-inner{display:flex;align-items:center;justify-content:center;gap:12px}
.demo-timer{font-variant-numeric:tabular-nums;font-weight:500}
.demo-timer.urgent{color:#fecaca;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
.demo-cta{background:#fff;color:#dc2626;padding:6px 12px;text-decoration:none;font-weight:600;font-size:12px;transition:transform .15s}
.demo-cta:hover{transform:scale(1.05)}
</style>
<script>
(function(){
const exp=` + strconv.FormatInt(expiresUnix, 10) + `*1000;
const timer=document.querySelector('.demo-timer');
const cta=document.querySelector('.demo-cta');
const update=()=>{
const left=Math.max(0,exp-Date.now());
const m=Math.floor(left/60000);
const s=Math.floor((left%60000)/1000);
timer.textContent=m+':'+(s<10?'0':'')+s+' remaining';
if(left<30000)timer.classList.add('urgent');
if(left<=0){
timer.textContent='Demo expired';
setTimeout(()=>{
const sub=location.hostname.split('.')[0];
location.href='` + redirectURL + `?expired=true&subdomain='+sub;
},2000);
return;
}
requestAnimationFrame(update);
};
update();
if(location.pathname.startsWith('/studio'))cta.textContent='View Site',cta.href='/posts';
})();
</script>`
}
func generateFakeAnalytics(days int) *tenant.AnalyticsSummary {
if days <= 0 {
days = 30
}
baseViews := 25 + rand.Intn(20)
var totalViews, totalVisitors int64
viewsByDay := make([]tenant.DailyStats, days)
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -days+i+1)
weekday := date.Weekday()
multiplier := 1.0
if weekday == time.Saturday || weekday == time.Sunday {
multiplier = 0.6
} else if weekday == time.Monday {
multiplier = 1.2
}
dailyViews := int64(float64(baseViews+rand.Intn(15)) * multiplier)
dailyVisitors := dailyViews * int64(65+rand.Intn(15)) / 100
totalViews += dailyViews
totalVisitors += dailyVisitors
viewsByDay[i] = tenant.DailyStats{
Date: date.Format("2006-01-02"),
Views: dailyViews,
Visitors: dailyVisitors,
}
}
return &tenant.AnalyticsSummary{
TotalViews: totalViews,
TotalPageViews: totalViews,
UniqueVisitors: totalVisitors,
TotalBandwidth: totalViews * 45000,
ViewsChange: float64(rand.Intn(300)-100) / 10,
ViewsByDay: viewsByDay,
TopPages: []tenant.PageStats{
{Path: "/", Views: totalViews * 25 / 100},
{Path: "/posts/shipping-a-side-project", Views: totalViews * 22 / 100},
{Path: "/posts/debugging-production-like-a-detective", Views: totalViews * 18 / 100},
{Path: "/posts/sqlite-in-production", Views: totalViews * 15 / 100},
{Path: "/posts", Views: totalViews * 12 / 100},
{Path: "/posts/my-2024-reading-list", Views: totalViews * 8 / 100},
},
TopReferrers: []tenant.ReferrerStats{
{Referrer: "Google", Views: totalViews * 30 / 100},
{Referrer: "Twitter/X", Views: totalViews * 20 / 100},
{Referrer: "GitHub", Views: totalViews * 15 / 100},
{Referrer: "Hacker News", Views: totalViews * 12 / 100},
{Referrer: "LinkedIn", Views: totalViews * 10 / 100},
{Referrer: "YouTube", Views: totalViews * 8 / 100},
{Referrer: "Reddit", Views: totalViews * 5 / 100},
},
Browsers: []tenant.NamedStat{
{Name: "Chrome", Count: totalVisitors * 55 / 100},
{Name: "Safari", Count: totalVisitors * 25 / 100},
{Name: "Firefox", Count: totalVisitors * 12 / 100},
{Name: "Edge", Count: totalVisitors * 8 / 100},
},
OS: []tenant.NamedStat{
{Name: "macOS", Count: totalVisitors * 45 / 100},
{Name: "Windows", Count: totalVisitors * 30 / 100},
{Name: "iOS", Count: totalVisitors * 15 / 100},
{Name: "Linux", Count: totalVisitors * 7 / 100},
{Name: "Android", Count: totalVisitors * 3 / 100},
},
Devices: []tenant.NamedStat{
{Name: "Desktop", Count: totalVisitors * 70 / 100},
{Name: "Mobile", Count: totalVisitors * 25 / 100},
{Name: "Tablet", Count: totalVisitors * 5 / 100},
},
Countries: []tenant.NamedStat{
{Name: "United States", Count: totalVisitors * 40 / 100},
{Name: "United Kingdom", Count: totalVisitors * 12 / 100},
{Name: "Germany", Count: totalVisitors * 10 / 100},
{Name: "Canada", Count: totalVisitors * 8 / 100},
{Name: "France", Count: totalVisitors * 6 / 100},
{Name: "Australia", Count: totalVisitors * 5 / 100},
},
}
}
func generateFakePostAnalytics(days int) *tenant.AnalyticsSummary {
if days <= 0 {
days = 30
}
baseViews := 5 + rand.Intn(8)
var totalViews int64
viewsByDay := make([]tenant.DailyStats, days)
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -days+i+1)
dailyViews := int64(baseViews + rand.Intn(6))
totalViews += dailyViews
viewsByDay[i] = tenant.DailyStats{
Date: date.Format("2006-01-02"),
Views: dailyViews,
}
}
return &tenant.AnalyticsSummary{
TotalViews: totalViews,
ViewsByDay: viewsByDay,
}
}

649
internal/server/platform.go Normal file
View file

@ -0,0 +1,649 @@
package server
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"github.com/writekitapp/writekit/internal/auth"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
var subdomainRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`)
func (s *Server) platformHome(w http.ResponseWriter, r *http.Request) {
content, err := templatesFS.ReadFile("templates/index.html")
if err != nil {
slog.Error("read index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
html := string(content)
html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981")
html = strings.ReplaceAll(html, "{{VERSION}}", "v1.0.0")
html = strings.ReplaceAll(html, "{{COMMIT}}", "dev")
html = strings.ReplaceAll(html, "{{DEMO_MINUTES}}", "15")
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
content, err := templatesFS.ReadFile("templates/404.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusNotFound)
w.Write(content)
}
func (s *Server) platformLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/signup", http.StatusFound)
}
func (s *Server) platformSignup(w http.ResponseWriter, r *http.Request) {
content, err := templatesFS.ReadFile("templates/signup.html")
if err != nil {
slog.Error("read signup template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
html := string(content)
html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981")
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
func (s *Server) platformDashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head><title>Dashboard - WriteKit</title></head>
<body>
<h1>Dashboard</h1>
<p>Create your blog or manage your existing one.</p>
</body>
</html>`))
}
func (s *Server) serveStaticAssets(w http.ResponseWriter, r *http.Request) {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
http.NotFound(w, r)
return
}
http.StripPrefix("/assets/", http.FileServer(http.FS(sub))).ServeHTTP(w, r)
}
func (s *Server) checkSubdomain(w http.ResponseWriter, r *http.Request) {
subdomain := strings.ToLower(r.URL.Query().Get("subdomain"))
if subdomain == "" {
jsonError(w, http.StatusBadRequest, "subdomain required")
return
}
if !subdomainRegex.MatchString(subdomain) {
jsonResponse(w, http.StatusOK, map[string]any{
"available": false,
"reason": "invalid format",
})
return
}
available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain)
if err != nil {
slog.Error("check subdomain", "error", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
jsonResponse(w, http.StatusOK, map[string]any{"available": available})
}
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r)
var req struct {
Subdomain string `json:"subdomain"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
subdomain := strings.ToLower(req.Subdomain)
if !subdomainRegex.MatchString(subdomain) {
jsonError(w, http.StatusBadRequest, "invalid subdomain format")
return
}
existing, err := s.database.GetTenantByOwner(r.Context(), userID)
if err != nil {
slog.Error("check existing tenant", "error", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
if existing != nil {
jsonResponse(w, http.StatusConflict, map[string]any{
"error": "you already have a blog",
"url": s.buildURL(existing.Subdomain),
})
return
}
available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain)
if err != nil {
slog.Error("check availability", "error", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
if !available {
jsonError(w, http.StatusConflict, "subdomain not available")
return
}
tenant, err := s.database.CreateTenant(r.Context(), userID, subdomain)
if err != nil {
slog.Error("create tenant", "error", err)
jsonError(w, http.StatusInternalServerError, "failed to create tenant")
return
}
if _, err := s.tenantPool.Get(tenant.ID); err != nil {
slog.Error("init tenant db", "tenant_id", tenant.ID, "error", err)
}
user, _ := s.database.GetUserByID(r.Context(), userID)
if user != nil {
if tenantDB, err := s.tenantPool.Get(tenant.ID); err == nil {
tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_name', ?) ON CONFLICT DO NOTHING`, user.Name)
tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_avatar', ?) ON CONFLICT DO NOTHING`, user.AvatarURL)
}
}
slog.Info("tenant created", "subdomain", subdomain, "user_id", userID, "tenant_id", tenant.ID)
jsonResponse(w, http.StatusCreated, map[string]any{
"subdomain": subdomain,
"url": s.buildURL(subdomain),
"tenant_id": tenant.ID,
})
}
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r)
tenant, err := s.database.GetTenantByOwner(r.Context(), userID)
if err != nil {
slog.Error("get tenant", "error", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
if tenant == nil {
jsonResponse(w, http.StatusOK, map[string]any{"has_tenant": false})
return
}
jsonResponse(w, http.StatusOK, map[string]any{
"has_tenant": true,
"tenant": map[string]any{
"id": tenant.ID,
"subdomain": tenant.Subdomain,
"url": s.buildURL(tenant.Subdomain),
"premium": tenant.Premium,
},
})
}
func jsonResponse(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, status int, msg string) {
jsonResponse(w, status, map[string]string{"error": msg})
}
func (s *Server) buildURL(subdomain string) string {
scheme := "https"
if env := os.Getenv("ENV"); env != "prod" {
scheme = "http"
}
return fmt.Sprintf("%s://%s.%s", scheme, subdomain, s.domain)
}
func (s *Server) createDemo(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
referer := r.Header.Get("Referer")
validOrigins := []string{
"https://" + s.domain,
"https://www." + s.domain,
}
if env := os.Getenv("ENV"); env != "prod" {
validOrigins = append(validOrigins,
"http://"+s.domain,
"http://www."+s.domain,
)
}
isValidOrigin := func(o string) bool {
for _, v := range validOrigins {
if o == v {
return true
}
}
return false
}
isValidReferer := func(ref string) bool {
for _, v := range validOrigins {
if strings.HasPrefix(ref, v) {
return true
}
}
return false
}
if origin != "" && !isValidOrigin(origin) {
jsonError(w, http.StatusForbidden, "forbidden")
return
}
if origin == "" && referer != "" && !isValidReferer(referer) {
jsonError(w, http.StatusForbidden, "forbidden")
return
}
var req struct {
Name string `json:"name"`
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == "" {
req.Name = "Demo User"
}
if req.Color == "" {
req.Color = "#10b981"
}
demo, err := s.database.CreateDemo(r.Context())
if err != nil {
slog.Error("create demo", "error", err)
jsonError(w, http.StatusInternalServerError, "failed to create demo")
return
}
s.tenantPool.MarkAsDemo(demo.ID)
if _, err := s.tenantPool.Get(demo.ID); err != nil {
slog.Error("init demo db", "demo_id", demo.ID, "error", err)
}
if err := s.seedDemoContent(demo.ID, req.Name, req.Color); err != nil {
slog.Error("seed demo content", "demo_id", demo.ID, "error", err)
}
slog.Info("demo created", "subdomain", demo.Subdomain, "demo_id", demo.ID, "expires_at", demo.ExpiresAt)
jsonResponse(w, http.StatusCreated, map[string]any{
"subdomain": demo.Subdomain,
"url": s.buildURL(demo.Subdomain),
"expires_at": demo.ExpiresAt.Format("2006-01-02T15:04:05Z"),
})
}
func (s *Server) seedDemoContent(demoID, authorName, accentColor string) error {
db, err := s.tenantPool.Get(demoID)
if err != nil {
return fmt.Errorf("get tenant pool: %w", err)
}
result, err := db.Exec(`INSERT INTO site_settings (key, value) VALUES
('site_name', ?),
('site_description', 'Thoughts on building software, developer tools, and the craft of engineering.'),
('accent_color', ?),
('author_name', ?),
('is_demo', 'true')
ON CONFLICT DO NOTHING`, authorName+"'s Blog", accentColor, authorName)
if err != nil {
return fmt.Errorf("insert settings: %w", err)
}
rows, _ := result.RowsAffected()
slog.Info("seed demo settings", "demo_id", demoID, "rows_affected", rows)
_, err = db.Exec(`INSERT INTO posts (id, slug, title, description, content_md, tags, cover_image, is_published, published_at, created_at, modified_at) VALUES
('demo-post-1', 'shipping-a-side-project',
'I Finally Shipped My Side Project',
'After mass years of abandoned repos, here is what finally worked.',
'I have mass abandoned side projects. We all do. But last month, I actually shipped one.
Here''s what I did differently this time.
## The Graveyard
Before we get to what worked, let me be honest about what didn''t. My GitHub is full of:
- [ ] A "better" todo app (mass of features planned, mass built)
- [ ] A CLI tool I used once
- [ ] Three different blog engines (ironic, I know)
- [x] This project finally shipped
The pattern was always the same: mass enthusiasm for a week, mass silence forever.
## What Changed
This time, I set one rule: **ship in two weeks or kill it**.
> "If you''re not embarrassed by the first version of your product, you''ve launched too late." Reid Hoffman
I printed that quote and stuck it on my monitor. Every time I wanted to add "just one more feature," I looked at it.
## The Stack
I kept it minimal:
| Layer | Choice | Why |
|-------|--------|-----|
| Frontend | React | I know it well |
| Backend | Go | Fast, simple deploys |
| Database | SQLite | No ops overhead |
| Hosting | Fly.io | $0 to start |
~~I originally planned to use Kubernetes~~ glad I didn''t. SQLite on a single server handles more traffic than I''ll ever get.
## The Launch
I posted on Twitter, mass likes. mass signups. Then... mass complaints about bugs I hadn''t tested for.
But here''s the thing: **those bugs only exist because I shipped**. I fixed them in a day. If I''d waited for "perfect," I''d still be tweaking CSS.
---
Ship something this week. mass needs to be big. Your mass abandoned project could be mass person''s favorite tool.',
'["career","shipping"]',
'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=1200&h=630&fit=crop',
1,
datetime('now', '-2 days'),
datetime('now', '-2 days'),
datetime('now', '-2 days')),
('demo-post-2', 'debugging-production-like-a-detective',
'Debugging Production Like a Detective',
'A systematic approach to finding bugs when console.log is not enough.',
'Last week, our API started returning 500 errors. Not always just enough to be infuriating. Here''s how I tracked it down.
## The Symptoms
Users reported "random failures." The logs showed:
` + "```" + `
error: connection refused
error: connection refused
error: context deadline exceeded
` + "```" + `
Helpful, right?
## Step 1: Gather Evidence
First, I needed data. I added structured logging:
` + "```go" + `
slog.Error("request failed",
"endpoint", r.URL.Path,
"user_id", userID,
"duration_ms", time.Since(start).Milliseconds(),
"error", err,
)
` + "```" + `
Within an hour, a pattern emerged:
| Time | Endpoint | Duration | Result |
|------|----------|----------|--------|
| 14:01 | /api/posts | 45ms | OK |
| 14:01 | /api/posts | 52ms | OK |
| 14:02 | /api/posts | 30,004ms | FAIL |
| 14:02 | /api/users | 48ms | OK |
The failures were always *exactly* 30 seconds our timeout value.
## Step 2: Form a Hypothesis
> The 30-second timeout suggested a connection hanging, not failing fast.
I suspected connection pool exhaustion. Our pool was set to 10 connections. Under load, requests would wait for a free connection, then timeout.
## Step 3: Test the Theory
I checked the pool stats:
` + "```sql" + `
SELECT count(*) FROM pg_stat_activity
WHERE application_name = ''myapp'';
` + "```" + `
Result: **10**. Exactly at the limit.
## The Fix
` + "```go" + `
db.SetMaxOpenConns(25) // was: 10
db.SetMaxIdleConns(10) // was: 2
db.SetConnMaxLifetime(time.Hour)
` + "```" + `
Three lines. Two days of debugging. Zero more timeouts.
---
The lesson: **when debugging production, be a detective, not a guesser**. Gather evidence, form hypotheses, test them.',
'["debugging","golang","databases"]',
'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&h=630&fit=crop',
1,
datetime('now', '-5 days'),
datetime('now', '-5 days'),
datetime('now', '-5 days')),
('demo-post-3', 'sqlite-in-production',
'Yes, You Can Use SQLite in Production',
'How to scale SQLite further than you think and when to finally migrate.',
'Every time I mention SQLite in production, someone says "that doesn''t scale." Let me share some numbers.
## The Reality
SQLite handles:
- **Millions** of reads per second
- **Thousands** of writes per second (with WAL mode)
- Databases up to **281 TB** (theoretical limit)
For context, that''s more than most apps will ever need.
## Configuration That Matters
The defaults are conservative. For production, I use:
` + "```sql" + `
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -64000; -- 64MB cache
PRAGMA busy_timeout = 5000;
` + "```" + `
This gives you:
| Setting | Default | Production | Impact |
|---------|---------|------------|--------|
| journal_mode | DELETE | WAL | Concurrent reads during writes |
| synchronous | FULL | NORMAL | 10x faster writes, still safe |
| cache_size | -2000 | -64000 | Fewer disk reads |
## When to Migrate
SQLite is **not** right when you need:
1. Multiple servers writing to the same database
2. ~~Horizontal scaling~~ (though read replicas now exist via Litestream)
3. Sub-millisecond writes under heavy contention
If you''re running a single server which is most apps SQLite is great.
> "I''ve never used a database that made me this happy." every SQLite user, probably
## Getting Started
Here''s my standard setup:
` + "```go" + `
db, err := sql.Open("sqlite3", "file:app.db?_journal=WAL&_timeout=5000")
if err != nil {
log.Fatal(err)
}
db.SetMaxOpenConns(1) // SQLite writes are serialized anyway
// Run migrations
if _, err := db.Exec(schema); err != nil {
log.Fatal(err)
}
` + "```" + `
---
Stop overengineering. Start with SQLite. Migrate when you *actually* hit limits you probably never will.',
'["sqlite","databases","architecture"]',
'https://images.unsplash.com/photo-1544383835-bda2bc66a55d?w=1200&h=630&fit=crop',
1,
datetime('now', '-8 days'),
datetime('now', '-8 days'),
datetime('now', '-8 days')),
('demo-post-4', 'my-2024-reading-list',
'My 2024 Reading List',
'',
'Here are the books that shaped my thinking this year.
## Technical Books
- **Designing Data-Intensive Applications** by Martin Kleppmann
- **The Pragmatic Programmer** by David Thomas
- **Staff Engineer** by Will Larson
## Non-Technical
- **The Mom Test** by Rob Fitzpatrick
- **Building a Second Brain** by Tiago Forte
More thoughts on each coming soon...',
'["books","learning"]',
'',
1,
datetime('now', '-12 days'),
datetime('now', '-12 days'),
datetime('now', '-12 days')),
('demo-draft-1', 'understanding-react-server-components',
'Understanding React Server Components',
'A deep dive into RSC architecture and when to use them.',
'React Server Components are confusing. Let me try to explain them.
## The Problem RSC Solves
Traditional React apps ship a lot of JavaScript to the browser. RSC lets you run components on the server and send only the HTML.
## Key Concepts
TODO: Add diagrams
## When to Use RSC
- Data fetching
- Heavy dependencies
- SEO-critical pages
## When NOT to Use RSC
- Interactive components
- Client state
- Event handlers
Still writing this one...',
'["react","architecture"]',
'',
0,
NULL,
datetime('now', '-1 days'),
datetime('now', '-2 hours')),
('demo-draft-2', 'vim-tricks-i-use-daily',
'Vim Tricks I Use Daily',
'The shortcuts that actually stuck after 5 years of using Vim.',
'After mass years of Vim, these are the commands I use daily.
## Navigation
- Ctrl+d and Ctrl+u for half page down/up
- zz to center current line
## Editing
Still collecting my favorites...',
'["vim","productivity"]',
'',
0,
NULL,
datetime('now', '-3 days'),
datetime('now', '-1 days'))
ON CONFLICT DO NOTHING`)
if err != nil {
return fmt.Errorf("insert posts: %w", err)
}
slog.Info("seed demo posts", "demo_id", demoID)
return nil
}
func (s *Server) ensureDemoSeeded(demoID string) {
db, err := s.tenantPool.Get(demoID)
if err != nil {
slog.Error("ensureDemoSeeded: get pool", "demo_id", demoID, "error", err)
return
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count)
if err != nil {
slog.Error("ensureDemoSeeded: count posts", "demo_id", demoID, "error", err)
return
}
if count > 0 {
return
}
slog.Info("re-seeding demo content", "demo_id", demoID)
if err := s.seedDemoContent(demoID, "Demo User", "#10b981"); err != nil {
slog.Error("ensureDemoSeeded: seed failed", "demo_id", demoID, "error", err)
}
}

View file

@ -0,0 +1,126 @@
package server
import (
"net/http"
"sync"
"time"
"github.com/writekitapp/writekit/internal/config"
)
type bucket struct {
tokens float64
lastFill time.Time
rateLimit int
}
type RateLimiter struct {
mu sync.RWMutex
buckets map[string]*bucket
}
func NewRateLimiter() *RateLimiter {
rl := &RateLimiter{
buckets: make(map[string]*bucket),
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) Allow(tenantID string, limit int) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
b, ok := rl.buckets[tenantID]
if !ok {
b = &bucket{
tokens: float64(limit),
lastFill: now,
rateLimit: limit,
}
rl.buckets[tenantID] = b
}
if b.rateLimit != limit {
b.rateLimit = limit
b.tokens = float64(limit)
}
elapsed := now.Sub(b.lastFill)
tokensToAdd := elapsed.Hours() * float64(limit)
b.tokens = min(b.tokens+tokensToAdd, float64(limit))
b.lastFill = now
if b.tokens >= 1 {
b.tokens--
return true
}
return false
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
rl.mu.Lock()
threshold := time.Now().Add(-1 * time.Hour)
for k, b := range rl.buckets {
if b.lastFill.Before(threshold) {
delete(rl.buckets, k)
}
}
rl.mu.Unlock()
}
}
func (s *Server) apiRateLimitMiddleware(rl *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
next.ServeHTTP(w, r)
return
}
t, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil {
next.ServeHTTP(w, r)
return
}
premium := t != nil && t.Premium
tierInfo := config.GetTierInfo(premium)
limit := tierInfo.Config.APIRateLimit
if !rl.Allow(tenantID, limit) {
w.Header().Set("X-RateLimit-Limit", itoa(limit))
w.Header().Set("X-RateLimit-Reset", "3600")
w.Header().Set("Retry-After", "60")
jsonError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
w.Header().Set("X-RateLimit-Limit", itoa(limit))
next.ServeHTTP(w, r)
})
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}

634
internal/server/reader.go Normal file
View file

@ -0,0 +1,634 @@
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/writekitapp/writekit/internal/tenant"
)
func (s *Server) readerRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/login/{provider}", s.readerLogin)
r.Get("/auth/callback", s.readerAuthCallback)
r.Get("/auth/providers", s.readerAuthProviders)
r.Group(func(r chi.Router) {
r.Use(s.readerAuthMiddleware)
r.Get("/config", s.getInteractionConfig)
r.Get("/posts/{slug}/comments", s.listComments)
r.Post("/posts/{slug}/comments", s.createComment)
r.Delete("/comments/{id}", s.deleteComment)
r.Get("/posts/{slug}/reactions", s.getReactions)
r.Post("/posts/{slug}/reactions", s.toggleReaction)
r.Get("/me", s.getReaderMe)
r.Post("/logout", s.readerLogout)
r.Get("/search", s.search)
})
return r
}
func (s *Server) getInteractionConfig(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
config := q.GetInteractionConfig(r.Context())
jsonResponse(w, http.StatusOK, config)
}
func (s *Server) listComments(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
comments, err := q.ListComments(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list comments")
return
}
jsonResponse(w, http.StatusOK, comments)
}
func (s *Server) createComment(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if q.GetSettingWithDefault(r.Context(), "comments_enabled") != "true" {
jsonError(w, http.StatusForbidden, "comments disabled")
return
}
var req struct {
Content string `json:"content"`
ParentID *int64 `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Content == "" {
jsonError(w, http.StatusBadRequest, "content required")
return
}
userID := getReaderUserID(r)
if userID == "" {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
// Get user info for validation
user, _ := q.GetUserByID(r.Context(), userID)
authorName := "Anonymous"
authorEmail := ""
if user != nil {
authorName = user.Name
authorEmail = user.Email
}
// Validate comment via plugins
runner := s.getPluginRunner(tenantID, db)
allowed, reason, _ := runner.TriggerValidation(r.Context(), "comment.validate", map[string]any{
"content": req.Content,
"authorName": authorName,
"authorEmail": authorEmail,
"postSlug": slug,
})
runner.Close()
if !allowed {
msg := "Comment rejected"
if reason != "" {
msg = reason
}
jsonError(w, http.StatusForbidden, msg)
return
}
comment := &tenant.Comment{
UserID: userID,
PostSlug: slug,
Content: req.Content,
ParentID: req.ParentID,
}
if err := q.CreateComment(r.Context(), comment); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create comment")
return
}
// Trigger comment.created hook
go func() {
runner := s.getPluginRunner(tenantID, db)
defer runner.Close()
// Get post info
post, _ := q.GetPost(r.Context(), slug)
postData := map[string]any{"slug": slug, "title": slug, "url": "/" + slug}
if post != nil {
postData["title"] = post.Title
}
runner.TriggerHook(r.Context(), "comment.created", map[string]any{
"comment": map[string]any{
"id": comment.ID,
"content": comment.Content,
"authorName": authorName,
"authorEmail": authorEmail,
"postSlug": slug,
"createdAt": comment.CreatedAt.Format(time.RFC3339),
},
"post": postData,
})
}()
jsonResponse(w, http.StatusCreated, comment)
}
func (s *Server) deleteComment(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
id := chi.URLParam(r, "id")
userID := getReaderUserID(r)
if userID == "" {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
var commentID int64
if _, err := json.Number(id).Int64(); err != nil {
jsonError(w, http.StatusBadRequest, "invalid comment id")
return
}
commentID, _ = json.Number(id).Int64()
comment, err := q.GetComment(r.Context(), commentID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get comment")
return
}
if comment == nil {
jsonError(w, http.StatusNotFound, "comment not found")
return
}
if comment.UserID != userID {
jsonError(w, http.StatusForbidden, "not your comment")
return
}
if err := q.DeleteComment(r.Context(), commentID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete comment")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) getReactions(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
counts, err := q.GetReactionCounts(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get reactions")
return
}
var userReactions []string
userID := getReaderUserID(r)
if userID != "" {
userReactions, _ = q.GetUserReactions(r.Context(), userID, slug)
} else if anonID := getAnonID(r); anonID != "" {
userReactions, _ = q.GetAnonReactions(r.Context(), anonID, slug)
}
jsonResponse(w, http.StatusOK, map[string]any{
"counts": counts,
"user": userReactions,
})
}
func (s *Server) toggleReaction(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if q.GetSettingWithDefault(r.Context(), "reactions_enabled") != "true" {
jsonError(w, http.StatusForbidden, "reactions disabled")
return
}
var req struct {
Emoji string `json:"emoji"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Emoji == "" {
jsonError(w, http.StatusBadRequest, "emoji required")
return
}
userID := getReaderUserID(r)
anonID := ""
requireAuth := q.GetSettingWithDefault(r.Context(), "reactions_require_auth") == "true"
if userID == "" {
if requireAuth {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
anonID = getOrCreateAnonID(w, r)
}
added, err := q.ToggleReaction(r.Context(), userID, anonID, slug, req.Emoji)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to toggle reaction")
return
}
jsonResponse(w, http.StatusOK, map[string]bool{"added": added})
}
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
query := r.URL.Query().Get("q")
if query == "" {
jsonResponse(w, http.StatusOK, []any{})
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
results, err := q.Search(r.Context(), query, 20)
if err != nil {
jsonResponse(w, http.StatusOK, []any{})
return
}
jsonResponse(w, http.StatusOK, results)
}
func (s *Server) getReaderMe(w http.ResponseWriter, r *http.Request) {
userID := getReaderUserID(r)
if userID == "" {
jsonResponse(w, http.StatusOK, map[string]any{"logged_in": false})
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
user, err := q.GetUserByID(r.Context(), userID)
if err != nil || user == nil {
jsonResponse(w, http.StatusOK, map[string]any{"logged_in": false})
return
}
jsonResponse(w, http.StatusOK, map[string]any{
"logged_in": true,
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"avatar_url": user.AvatarURL,
},
})
}
func (s *Server) readerLogout(w http.ResponseWriter, r *http.Request) {
token := extractReaderToken(r)
if token == "" {
w.WriteHeader(http.StatusNoContent)
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err == nil {
q := tenant.NewQueries(db)
q.DeleteSession(r.Context(), token)
}
http.SetCookie(w, &http.Cookie{
Name: "reader_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusNoContent)
}
func getReaderUserID(r *http.Request) string {
if id, ok := r.Context().Value(readerUserIDKey).(string); ok {
return id
}
return ""
}
type readerCtxKey string
const readerUserIDKey readerCtxKey = "readerUserID"
func (s *Server) readerAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractReaderToken(r)
if token == "" {
next.ServeHTTP(w, r)
return
}
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
next.ServeHTTP(w, r)
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
next.ServeHTTP(w, r)
return
}
q := tenant.NewQueries(db)
session, err := q.ValidateSession(r.Context(), token)
if err != nil || session == nil {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), readerUserIDKey, session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func extractReaderToken(r *http.Request) string {
if cookie, err := r.Cookie("reader_session"); err == nil && cookie.Value != "" {
return cookie.Value
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ""
}
func (s *Server) readerLogin(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider")
if provider != "google" && provider != "github" && provider != "discord" {
jsonError(w, http.StatusBadRequest, "invalid provider")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
redirect := r.URL.Query().Get("redirect")
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
tenantInfo, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil || tenantInfo == nil {
jsonError(w, http.StatusNotFound, "tenant not found")
return
}
domain := tenantInfo.CustomDomain
if domain == "" {
domain = tenantInfo.Subdomain + "." + s.domain
}
callbackURL := fmt.Sprintf("https://%s/api/reader/auth/callback", domain)
if redirect != "" {
callbackURL += "?redirect=" + redirect
}
authURL := fmt.Sprintf("%s/auth/%s?tenant=%s&callback=%s",
baseURL, provider, tenantID, callbackURL)
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}
func (s *Server) readerAuthCallback(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
redirect := r.URL.Query().Get("redirect")
if token == "" {
jsonError(w, http.StatusBadRequest, "missing token")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
userURL := baseURL + "/auth/user?token=" + token
resp, err := http.Get(userURL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "auth failed")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
jsonError(w, http.StatusUnauthorized, "invalid token: "+string(body))
return
}
var platformUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&platformUser); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to parse user")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
user, err := q.GetUserByID(r.Context(), platformUser.ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
if user == nil {
user = &tenant.User{
ID: platformUser.ID,
Email: platformUser.Email,
Name: platformUser.Name,
AvatarURL: platformUser.AvatarURL,
}
if err := q.CreateUser(r.Context(), user); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create user")
return
}
}
session, err := q.CreateSession(r.Context(), user.ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create session")
return
}
tenantInfo, _ := s.database.GetTenantByID(r.Context(), tenantID)
secure := tenantInfo != nil && !strings.HasPrefix(tenantInfo.Subdomain, "localhost")
http.SetCookie(w, &http.Cookie{
Name: "reader_session",
Value: session.Token,
Path: "/",
Expires: session.ExpiresAt,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
redirect = "/"
}
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
}
func (s *Server) readerAuthProviders(w http.ResponseWriter, r *http.Request) {
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
resp, err := http.Get(baseURL + "/auth/providers")
if err != nil {
jsonResponse(w, http.StatusOK, map[string]any{"providers": []any{}})
return
}
defer resp.Body.Close()
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
jsonResponse(w, http.StatusOK, map[string]any{"providers": []any{}})
return
}
jsonResponse(w, http.StatusOK, result)
}
func getOrCreateAnonID(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie("anon_id"); err == nil && cookie.Value != "" {
return cookie.Value
}
b := make([]byte, 16)
rand.Read(b)
anonID := hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: "anon_id",
Value: anonID,
Path: "/",
MaxAge: 365 * 24 * 60 * 60,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return anonID
}
func getAnonID(r *http.Request) string {
if cookie, err := r.Cookie("anon_id"); err == nil {
return cookie.Value
}
return ""
}

163
internal/server/server.go Normal file
View file

@ -0,0 +1,163 @@
package server
import (
"context"
"database/sql"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/writekitapp/writekit/internal/auth"
"github.com/writekitapp/writekit/internal/cloudflare"
"github.com/writekitapp/writekit/internal/db"
"github.com/writekitapp/writekit/internal/imaginary"
"github.com/writekitapp/writekit/internal/storage"
"github.com/writekitapp/writekit/internal/tenant"
)
type Server struct {
router chi.Router
database *db.DB
tenantPool *tenant.Pool
tenantCache *tenant.Cache
storage storage.Client
imaginary *imaginary.Client
cloudflare *cloudflare.Client
rateLimiter *RateLimiter
domain string
jarvisURL string
stopCleanup chan struct{}
}
func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient storage.Client) *Server {
domain := os.Getenv("DOMAIN")
if domain == "" {
domain = "writekit.dev"
}
jarvisURL := os.Getenv("JARVIS_URL")
if jarvisURL == "" {
jarvisURL = "http://localhost:8090"
}
var imgClient *imaginary.Client
if url := os.Getenv("IMAGINARY_URL"); url != "" {
imgClient = imaginary.New(url)
}
cfClient := cloudflare.NewClient()
s := &Server{
router: chi.NewRouter(),
database: database,
tenantPool: pool,
tenantCache: cache,
storage: storageClient,
imaginary: imgClient,
cloudflare: cfClient,
rateLimiter: NewRateLimiter(),
domain: domain,
jarvisURL: jarvisURL,
stopCleanup: make(chan struct{}),
}
s.router.Use(middleware.Logger)
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.Compress(5))
s.routes()
go s.cleanupDemos()
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
func (s *Server) routes() {
s.router.HandleFunc("/*", s.route)
}
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
if host == s.domain || host == "www."+s.domain {
s.servePlatform(w, r)
return
}
if strings.HasSuffix(host, "."+s.domain) {
subdomain := strings.TrimSuffix(host, "."+s.domain)
s.serveBlog(w, r, subdomain)
return
}
s.notFound(w, r)
}
func (s *Server) servePlatform(w http.ResponseWriter, r *http.Request) {
mux := chi.NewRouter()
mux.NotFound(s.notFound)
mux.Get("/", s.platformHome)
mux.Get("/login", s.platformLogin)
mux.Get("/signup", s.platformSignup)
mux.Get("/signup/complete", s.platformSignup)
mux.Get("/dashboard", s.platformDashboard)
mux.Handle("/assets/*", http.HandlerFunc(s.serveStaticAssets))
mux.Mount("/auth", auth.NewHandler(s.database).Routes())
mux.Route("/api", func(r chi.Router) {
r.Get("/tenant/check", s.checkSubdomain)
r.Post("/demo", s.createDemo)
r.Group(func(r chi.Router) {
r.Use(auth.SessionMiddleware(s.database))
r.Post("/tenant", s.createTenant)
r.Get("/tenant", s.getTenant)
})
})
mux.ServeHTTP(w, r)
}
func (s *Server) cleanupDemos() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
expired, err := s.database.CleanupExpiredDemos(context.Background())
if err != nil {
slog.Error("cleanup expired demos", "error", err)
continue
}
for _, d := range expired {
s.tenantPool.Evict(d.ID)
s.tenantCache.Delete(d.Subdomain)
slog.Info("cleaned up expired demo", "demo_id", d.ID, "subdomain", d.Subdomain)
}
case <-s.stopCleanup:
return
}
}
}
func (s *Server) Close() {
close(s.stopCleanup)
}
// getPluginRunner returns a PluginRunner for the given tenant
func (s *Server) getPluginRunner(tenantID string, db *sql.DB) *tenant.PluginRunner {
return tenant.NewPluginRunner(db, tenantID)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="writekit-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#10b981"/>
<stop offset="100%" style="stop-color:#06b6d4"/>
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="256" cy="256" r="256" fill="url(#writekit-gradient)"/>
<!-- Open book icon (scaled and centered) -->
<g transform="translate(96, 96) scale(13.33)">
<path
fill="none"
stroke="white"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 961 B

2052
internal/server/studio.go Normal file

File diff suppressed because it is too large Load diff

113
internal/server/sync.go Normal file
View file

@ -0,0 +1,113 @@
package server
import (
"context"
"log"
"time"
"github.com/writekitapp/writekit/internal/tenant"
)
func (s *Server) StartAnalyticsSync() {
if !s.cloudflare.IsConfigured() {
return
}
go s.runAnalyticsSync()
}
func (s *Server) runAnalyticsSync() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
s.syncYesterdayAnalytics()
for range ticker.C {
s.syncYesterdayAnalytics()
}
}
func (s *Server) syncYesterdayAnalytics() {
ctx := context.Background()
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
tenants, err := s.database.ListTenants(ctx)
if err != nil {
log.Printf("analytics sync: list tenants: %v", err)
return
}
for _, t := range tenants {
s.syncTenantAnalytics(ctx, t.ID, t.Subdomain, yesterday)
}
demos, err := s.database.ListActiveDemos(ctx)
if err != nil {
log.Printf("analytics sync: list demos: %v", err)
return
}
for _, d := range demos {
s.syncTenantAnalytics(ctx, d.ID, d.Subdomain, yesterday)
}
}
func (s *Server) syncTenantAnalytics(ctx context.Context, tenantID, subdomain, date string) {
hostname := subdomain + "." + s.domain
tenantDB, err := s.tenantPool.Get(tenantID)
if err != nil {
log.Printf("analytics sync: get tenant db %s: %v", tenantID, err)
return
}
q := tenant.NewQueries(tenantDB)
has, err := q.HasArchivedDate(ctx, date)
if err != nil {
log.Printf("analytics sync: check archived %s: %v", tenantID, err)
return
}
if has {
return
}
cfData, err := s.cloudflare.GetAnalytics(ctx, 1, hostname)
if err != nil {
log.Printf("analytics sync: fetch cf data %s: %v", tenantID, err)
return
}
if cfData == nil || len(cfData.Daily) == 0 {
return
}
day := cfData.Daily[0]
archived := &tenant.ArchivedDay{
Date: day.Date,
Requests: day.Requests,
PageViews: day.PageViews,
UniqueVisitors: day.Visitors,
Bandwidth: day.Bandwidth,
}
for _, b := range cfData.Browsers {
archived.Browsers = append(archived.Browsers, tenant.NamedStat{Name: b.Name, Count: b.Count})
}
for _, o := range cfData.OS {
archived.OS = append(archived.OS, tenant.NamedStat{Name: o.Name, Count: o.Count})
}
for _, d := range cfData.Devices {
archived.Devices = append(archived.Devices, tenant.NamedStat{Name: d.Name, Count: d.Count})
}
for _, c := range cfData.Countries {
archived.Countries = append(archived.Countries, tenant.NamedStat{Name: c.Name, Count: c.Count})
}
for _, p := range cfData.Paths {
archived.Paths = append(archived.Paths, tenant.PageStats{Path: p.Path, Views: p.Requests})
}
if err := q.SaveDailyAnalytics(ctx, archived); err != nil {
log.Printf("analytics sync: save archived %s: %v", tenantID, err)
}
}

View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 — WriteKit</title>
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico" />
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg" />
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--primary:#10b981}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
.mono{font-family:'SF Mono','Fira Code','Consolas',monospace}
.layout{display:grid;grid-template-columns:200px 1fr;min-height:100vh}
aside{padding:2rem 1.5rem;border-right:1px solid var(--border);position:sticky;top:0;height:100vh;display:flex;flex-direction:column}
.sidebar-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.25rem}
.sidebar-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2.5rem}
.sidebar-nav{display:flex;flex-direction:column;gap:0.25rem}
.sidebar-nav a{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);text-decoration:none;padding:0.5rem 0;transition:color 0.15s}
.sidebar-nav a:hover{color:var(--text)}
.sidebar-footer{margin-top:auto}
.env-badge{font-family:'SF Mono','Fira Code',monospace;display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--primary);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:var(--primary)}
main{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem}
.error-content{text-align:center;max-width:400px}
.error-code{font-family:'SF Mono','Fira Code',monospace;font-size:120px;font-weight:600;letter-spacing:-0.05em;line-height:1;color:var(--border);margin-bottom:1rem}
.error-title{font-size:1.5rem;font-weight:500;letter-spacing:-0.02em;margin-bottom:0.75rem}
.error-message{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);margin-bottom:2rem;line-height:1.7}
.error-actions{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap}
.btn-primary{padding:12px 24px;background:var(--text);color:var(--bg);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;transition:all 0.2s}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
.btn-secondary{padding:12px 24px;background:transparent;color:var(--text);border:1px solid var(--border);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;transition:all 0.15s}
.btn-secondary:hover{border-color:var(--text)}
@media(max-width:900px){.layout{grid-template-columns:1fr}aside{display:none}}
</style>
</head>
<body>
<div class="layout">
<aside>
<div>
<div class="sidebar-logo">WriteKit</div>
<div class="sidebar-tagline">Blogging Platform</div>
</div>
<nav class="sidebar-nav">
<a href="/">Home</a>
<a href="/signup" style="color:var(--primary)">Create Blog →</a>
</nav>
<div class="sidebar-footer">
<div class="env-badge">ALPHA</div>
</div>
</aside>
<main>
<div class="error-content">
<div class="error-code">404</div>
<h1 class="error-title">Page not found</h1>
<p class="error-message">The page you're looking for doesn't exist or may have been moved.</p>
<div class="error-actions">
<a href="/" class="btn-primary">Go Home</a>
<a href="/signup" class="btn-secondary">Create a Blog</a>
</div>
</div>
</main>
</div>
</body>
</html>

View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Demo Expired — WriteKit</title>
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico"/>
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg"/>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--primary:#10b981}
body{font-family:'SF Mono','Fira Code','Consolas',monospace;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem}
.container{max-width:480px;width:100%}
.header{margin-bottom:3rem}
.logo{font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.5rem}
.subdomain{font-size:12px;color:var(--muted)}
.title{font-family:system-ui,-apple-system,sans-serif;font-size:2rem;font-weight:400;letter-spacing:-0.03em;margin-bottom:1rem}
.description{color:var(--muted);line-height:1.6;margin-bottom:2.5rem}
.section{margin-bottom:2rem}
.signup-btn{display:block;width:100%;padding:1rem 1.5rem;background:linear-gradient(135deg,#10b981,#06b6d4);color:white;text-decoration:none;font-family:inherit;font-size:14px;font-weight:500;text-align:center;border:none;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s}
.signup-btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(16,185,129,0.3)}
.signup-note{font-size:11px;color:var(--muted);margin-top:0.75rem;text-align:center}
.divider{display:flex;align-items:center;gap:1rem;margin:2rem 0;color:var(--muted);font-size:12px}
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
.secondary-actions{display:flex;gap:1rem}
.secondary-btn{flex:1;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.875rem 1rem;background:transparent;color:var(--text);text-decoration:none;font-family:inherit;font-size:13px;border:1px solid var(--border);transition:all 0.15s}
.secondary-btn:hover{border-color:var(--text);background:var(--border)}
.secondary-btn svg{width:16px;height:16px}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">WriteKit</div>
<div class="subdomain">{{SUBDOMAIN_TEXT}}</div>
</div>
<h1 class="title">Demo Expired</h1>
<p class="description">Your demo session has ended. Want to keep your blog? Sign up to make it permanent — it's free.</p>
<div class="section">
<a href="/signup" class="signup-btn">Keep My Blog — Sign Up Free</a>
<p class="signup-note">No credit card required</p>
</div>
<div class="divider">or</div>
<div class="secondary-actions">
<a href="/discord" class="secondary-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
Join Discord
</a>
<a href="/" class="secondary-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Try Again
</a>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WriteKit — Full Blogging Platform. Lightweight. Yours.</title>
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico" />
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--accent:{{ACCENT}};--primary:#10b981}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
.mono{font-family:'SF Mono','Fira Code','Consolas',monospace}
.layout{display:grid;grid-template-columns:200px 1fr;min-height:100vh}
aside{padding:2rem 1.5rem;border-right:1px solid var(--border);position:sticky;top:0;height:100vh;display:flex;flex-direction:column}
.sidebar-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.25rem}
.sidebar-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2.5rem}
.sidebar-nav{display:flex;flex-direction:column;gap:0.25rem}
.sidebar-nav a{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);text-decoration:none;padding:0.5rem 0;transition:color 0.15s}
.sidebar-nav a:hover{color:var(--text)}
.sidebar-divider{height:1px;background:var(--border);margin:1rem 0}
.sidebar-label{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:0.5rem}
.sidebar-footer{margin-top:auto}
.env-badge{font-family:'SF Mono','Fira Code',monospace;display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--accent);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:var(--accent)}
.version-badge{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);opacity:0.5;margin-top:0.5rem;cursor:default;transition:opacity 0.15s}
.version-badge:hover{opacity:1}
main{min-width:0}
.hero{position:relative;padding:5rem 3rem 4rem;border-bottom:1px solid var(--border);overflow:hidden}
.hero-canvas{position:absolute;top:0;right:0;width:45%;height:100%;opacity:0.6;mask-image:linear-gradient(to left,black,transparent);-webkit-mask-image:linear-gradient(to left,black,transparent)}
.hero>*:not(.hero-canvas){position:relative;z-index:1}
.hero-label{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:1.5rem}
.hero h1{font-family:system-ui,-apple-system,sans-serif;font-size:clamp(2rem,4vw,3rem);font-weight:500;letter-spacing:-0.03em;line-height:1.15;margin-bottom:1.5rem;max-width:600px}
.hero-sub{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);max-width:550px;line-height:1.7}
.hero-sub code{color:var(--text);background:var(--border);padding:0.15em 0.4em;font-size:0.95em}
.section{padding:4rem 3rem;border-bottom:1px solid var(--border)}
.section-label{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:2rem}
.hero-cta{margin-top:2.5rem}
.hero-cta .demo-btn{width:auto;padding:14px 32px;font-size:14px}
.hero-cta .demo-note{text-align:left}
.section-features{border-top:1px solid var(--border);border-bottom:1px solid var(--border);margin-top:-1px}
.features-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--border)}
.feature{background:var(--bg);padding:2rem}
.feature-num{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);margin-bottom:0.75rem}
.feature h3{font-size:1rem;font-weight:500;margin-bottom:0.5rem;letter-spacing:-0.01em}
.feature p{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);line-height:1.6}
.demo-btn{width:100%;margin-top:16px;padding:12px 24px;background:var(--text);color:var(--bg);border:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.2s}
.demo-btn:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
.demo-btn:disabled{opacity:0.4;cursor:not-allowed}
.demo-note{margin-top:1rem;font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-align:center}
.mission{max-width:650px}
.mission h2{font-size:1.5rem;font-weight:400;letter-spacing:-0.02em;line-height:1.5;margin-bottom:1.5rem}
.mission p{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);line-height:1.7}
.section-cta{text-align:center;padding:5rem 3rem;margin-top:-1px}
.cta-content h2{font-size:1.75rem;font-weight:500;letter-spacing:-0.02em;margin-bottom:0.75rem}
.cta-content p{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);margin-bottom:2rem}
.cta-buttons{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap}
.cta-primary{padding:14px 32px;background:var(--text);color:var(--bg);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:14px;transition:all 0.2s}
.cta-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
.cta-secondary{padding:14px 32px;background:transparent;color:var(--text);border:1px solid var(--border);font-family:'SF Mono','Fira Code',monospace;font-size:14px;cursor:pointer;transition:all 0.2s}
.cta-secondary:hover{border-color:var(--text)}
footer{padding:4rem 3rem;background:var(--text);color:var(--bg);margin-top:-1px}
.footer-content{display:flex;justify-content:space-between;align-items:flex-start;gap:3rem;max-width:900px}
.footer-brand{flex:1}
.footer-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:16px;margin-bottom:0.5rem}
.footer-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);margin-bottom:1.5rem}
.footer-copy{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted)}
.footer-links{display:flex;gap:3rem}
.footer-col h4{font-family:'SF Mono','Fira Code',monospace;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;color:var(--muted);margin-bottom:1rem;font-weight:normal}
.footer-col a{display:flex;align-items:center;gap:0.5rem;font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--bg);text-decoration:none;padding:0.35rem 0;transition:opacity 0.15s}
.footer-col a:hover{opacity:0.7}
.footer-col a svg{width:16px;height:16px}
.demo-modal{display:none;position:fixed;inset:0;z-index:1000;align-items:center;justify-content:center}
.demo-modal.active{display:flex}
.demo-modal-backdrop{position:absolute;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)}
.demo-modal-content{position:relative;background:white;border:1px solid var(--border);width:100%;max-width:420px;margin:1rem;box-shadow:0 25px 50px -12px rgba(0,0,0,0.25)}
.demo-step{display:none;padding:2rem}
.demo-step.active{display:block;animation:fadeIn 0.3s ease}
.demo-step-header{margin-bottom:1.5rem}
.demo-step-indicator{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;display:block;margin-bottom:0.75rem}
.demo-step-header h2{font-size:1.25rem;font-weight:500;margin-bottom:0.5rem}
.demo-step-header p{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
.demo-name-input{width:100%;padding:14px 16px;border:1px solid var(--border);font-family:inherit;font-size:15px;outline:none;transition:border-color 0.15s}
.demo-name-input:focus{border-color:var(--text)}
.demo-step-footer{display:flex;justify-content:flex-end;gap:0.75rem;margin-top:1.5rem}
.demo-next,.demo-launch{padding:12px 24px;background:var(--text);color:var(--bg);border:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.2s}
.demo-next:hover:not(:disabled),.demo-launch:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
.demo-next:disabled,.demo-launch:disabled{opacity:0.4;cursor:not-allowed}
.demo-back{padding:12px 24px;background:transparent;color:var(--muted);border:1px solid var(--border);font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.15s}
.demo-back:hover{border-color:var(--text);color:var(--text)}
.demo-launch{background:linear-gradient(135deg,#10b981,#06b6d4)}
.demo-launch:hover:not(:disabled){box-shadow:0 8px 24px rgba(16,185,129,0.3)}
.color-picker{display:flex;gap:0.75rem;flex-wrap:wrap}
.color-swatch{width:48px;height:48px;border:2px solid transparent;cursor:pointer;transition:all 0.15s;position:relative}
.color-swatch:hover{transform:scale(1.1)}
.color-swatch.selected{border-color:var(--text)}
.color-swatch.selected::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:white;font-size:18px;text-shadow:0 1px 2px rgba(0,0,0,0.3)}
.launch-progress{margin-top:1.5rem;padding:1rem;background:#f5f5f5;border:1px solid var(--border)}
.launch-progress.active{display:block;animation:fadeIn 0.3s ease}
.progress-step{display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0;font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
.progress-step.active{color:var(--text)}
.progress-step.done{color:var(--primary)}
.progress-dot{width:8px;height:8px;background:var(--border);transition:all 0.3s}
.progress-step.active .progress-dot{background:var(--text);animation:pulse 1s infinite}
.progress-step.done .progress-dot{background:var(--primary)}
.launch-success{text-align:center;padding:2rem}
.launch-success.active{display:block;animation:successPop 0.5s cubic-bezier(0.34,1.56,0.64,1)}
.success-icon{width:48px;height:48px;background:linear-gradient(135deg,#10b981,#06b6d4);display:flex;align-items:center;justify-content:center;margin:0 auto 1rem}
.success-icon svg{width:24px;height:24px;color:white}
.success-url{font-family:'SF Mono','Fira Code',monospace;font-size:14px;font-weight:500;margin-bottom:0.5rem}
.success-redirect{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
.demo-modal .launch-progress{display:block;margin-top:0;padding:0;background:transparent;border:none}
.demo-modal .launch-success{display:block;padding:1rem 0}
@keyframes fadeIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.3)}}
@keyframes successPop{0%{transform:scale(0.8);opacity:0}100%{transform:scale(1);opacity:1}}
@media(max-width:1024px){.features-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:900px){.layout{grid-template-columns:1fr}aside{position:relative;height:auto;border-right:none;border-bottom:1px solid var(--border);flex-direction:row;align-items:center;justify-content:space-between;padding:1rem 1.5rem}.sidebar-tagline{margin-bottom:0}.sidebar-nav,.sidebar-divider,.sidebar-label,.sidebar-footer{display:none}.hero,.section{padding:3rem 2rem}footer{padding:3rem 2rem}.footer-content{flex-direction:column}}
@media(max-width:768px){.features-grid{grid-template-columns:1fr}}
@media(max-width:480px){.hero,.section{padding:2rem 1.5rem}.footer-links{flex-direction:column;gap:2rem}}
</style>
</head>
<body>
<div class="layout">
<aside>
<div>
<div class="sidebar-logo">WriteKit</div>
<div class="sidebar-tagline">Blogging Platform</div>
</div>
<nav class="sidebar-nav">
<a href="#why">Why WriteKit</a>
<a href="#features">Features</a>
<a href="/signup" style="color:var(--primary)">Create Blog →</a>
</nav>
<div class="sidebar-divider"></div>
<div class="sidebar-label">Resources</div>
<nav class="sidebar-nav">
<a href="/docs">Documentation</a>
<a href="/discord">Discord</a>
</nav>
<div class="sidebar-footer">
<div class="env-badge">ALPHA</div>
<div class="version-badge" title="{{COMMIT}}">{{VERSION}}</div>
</div>
</aside>
<main>
<section class="hero">
<canvas class="hero-canvas" id="dither-canvas"></canvas>
<p class="hero-label">Blog Hosting for Developers / 2025</p>
<h1>Your Words,<br>Your Platform</h1>
<p class="hero-sub">Spin up a beautiful, fast blog in seconds. <code>SQLite</code>-powered, <code>markdown</code>-native, infinitely customizable.</p>
<div class="hero-cta">
<button class="demo-btn" id="try-demo">Try Demo</button>
<p class="demo-note">{{DEMO_MINUTES}} minute demo. <a href="/signup" style="color:var(--primary)">Create a real blog</a> instead.</p>
</div>
</section>
<section class="section" id="why">
<p class="section-label">Why WriteKit</p>
<div class="mission">
<h2>We built WriteKit because blogging platforms got complicated.</h2>
<p>Ghost is heavy. Hashnode is bloated. Medium doesn't care about developers. Hugo outputs static sites — great, until you want comments, logins and analytics without bolting on five services.<br><br>WriteKit is a fully featured platform for developers. Comments, reactions, search, analytics, monetization, API — everything works out of the box. Deploy in seconds, own your data forever.</p>
</div>
</section>
<section class="section-features" id="features">
<div class="features-grid">
<div class="feature"><p class="feature-num">01</p><h3>Comments & Reactions</h3><p>Threaded comments and emoji reactions. No Disqus, no third-party scripts.</p></div>
<div class="feature"><p class="feature-num">02</p><h3>Full-text Search</h3><p>SQLite FTS5 powers instant search. Fast, local, no external service.</p></div>
<div class="feature"><p class="feature-num">03</p><h3>Privacy-first Analytics</h3><p>Views, referrers, browsers — no cookies, no tracking pixels.</p></div>
<div class="feature"><p class="feature-num">04</p><h3>REST API</h3><p>Full programmatic access. Create posts, manage content, build integrations.</p></div>
<div class="feature"><p class="feature-num">05</p><h3>Markdown Native</h3><p>Write how you already write. YAML frontmatter, syntax highlighting.</p></div>
<div class="feature"><p class="feature-num">06</p><h3>Custom Domains</h3><p>Your domain or *.writekit.dev. SSL included automatically.</p></div>
<div class="feature"><p class="feature-num">07</p><h3>Own Your Data</h3><p>Export anytime. JSON, Markdown, full backup. No lock-in ever.</p></div>
<div class="feature"><p class="feature-num">08</p><h3>One-click Deploy</h3><p>No DevOps required. One button, your instance is live.</p></div>
</div>
</section>
<section class="section section-cta" id="cta">
<div class="cta-content">
<h2>Ready to start writing?</h2>
<p>Deploy your blog in seconds. No credit card required.</p>
<div class="cta-buttons">
<a href="/signup" class="cta-primary">Create Your Blog</a>
<button class="cta-secondary" id="try-demo-bottom">Try Demo First</button>
</div>
</div>
</section>
<div class="demo-modal" id="demo-modal">
<div class="demo-modal-backdrop"></div>
<div class="demo-modal-content">
<div class="demo-step active" data-step="1">
<div class="demo-step-header">
<span class="demo-step-indicator">1 / 2</span>
<h2>What's your name?</h2>
<p>We'll use this to personalize your blog.</p>
</div>
<input type="text" id="demo-name" class="demo-name-input" placeholder="Your name" autofocus autocomplete="off">
<div class="demo-step-footer">
<button class="demo-back" id="demo-skip">Skip</button>
<button class="demo-next" id="demo-next-1" disabled>Next →</button>
</div>
</div>
<div class="demo-step" data-step="2">
<div class="demo-step-header">
<span class="demo-step-indicator">2 / 2</span>
<h2>Pick a color</h2>
<p>Choose an accent color for your blog.</p>
</div>
<div class="color-picker">
<button class="color-swatch selected" data-color="#10b981" style="background:#10b981" title="Emerald"></button>
<button class="color-swatch" data-color="#3b82f6" style="background:#3b82f6" title="Blue"></button>
<button class="color-swatch" data-color="#8b5cf6" style="background:#8b5cf6" title="Purple"></button>
<button class="color-swatch" data-color="#f97316" style="background:#f97316" title="Orange"></button>
<button class="color-swatch" data-color="#ef4444" style="background:#ef4444" title="Rose"></button>
<button class="color-swatch" data-color="#64748b" style="background:#64748b" title="Slate"></button>
</div>
<div class="demo-step-footer">
<button class="demo-back" id="demo-back-2">← Back</button>
<button class="demo-launch" id="demo-launch">Launch Demo</button>
</div>
</div>
<div class="demo-step" data-step="3">
<div class="demo-step-header"><h2>Launching your demo...</h2></div>
<div class="launch-progress active">
<div class="progress-step" data-step="1"><span class="progress-dot"></span><span>Creating database...</span></div>
<div class="progress-step" data-step="2"><span class="progress-dot"></span><span>Configuring settings...</span></div>
<div class="progress-step" data-step="3"><span class="progress-dot"></span><span>Adding welcome post...</span></div>
<div class="progress-step" data-step="4"><span class="progress-dot"></span><span>Ready!</span></div>
</div>
</div>
<div class="demo-step" data-step="4">
<div class="launch-success active">
<div class="success-icon"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg></div>
<div class="success-url" id="success-url"></div>
<div class="success-redirect">Redirecting you now...</div>
</div>
</div>
</div>
</div>
<footer>
<div class="footer-content">
<div class="footer-brand">
<div class="footer-logo">WriteKit</div>
<div class="footer-tagline">Your Words, Your Platform</div>
<div class="footer-copy">&copy; 2025 WriteKit. All rights reserved.</div>
</div>
<div class="footer-links">
<div class="footer-col">
<h4>Product</h4>
<a href="#features">Features</a>
<a href="/signup">Create Blog</a>
<a href="/docs">Documentation</a>
</div>
<div class="footer-col">
<h4>Community</h4>
<a href="/discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>Discord</a>
</div>
</div>
</div>
</footer>
</main>
</div>
<script>
const $ = s => document.querySelector(s)
const $$ = s => document.querySelectorAll(s)
const sleep = ms => new Promise(r => setTimeout(r, ms))
const modal = $('#demo-modal')
const backdrop = modal.querySelector('.demo-modal-backdrop')
const nameInput = $('#demo-name')
const nextBtn = $('#demo-next-1')
const backBtn = $('#demo-back-2')
const launchBtn = $('#demo-launch')
const successUrl = $('#success-url')
const colorSwatches = $$('.color-swatch')
let demoName = ''
let demoColor = '#10b981'
const openDemoModal = () => {
modal.classList.add('active')
setTimeout(() => nameInput.focus(), 100)
}
const resetModal = () => {
nameInput.value = ''
demoName = ''
nextBtn.disabled = true
goToModalStep(1)
colorSwatches.forEach(s => s.classList.remove('selected'))
colorSwatches[0].classList.add('selected')
demoColor = '#10b981'
}
const goToModalStep = step => {
modal.querySelectorAll('.demo-step').forEach(el => el.classList.remove('active'))
modal.querySelector(`.demo-step[data-step="${step}"]`).classList.add('active')
if (step === 1) setTimeout(() => nameInput.focus(), 100)
}
const setProgressStep = n => {
modal.querySelectorAll('.progress-step').forEach(el => {
const step = parseInt(el.dataset.step)
el.classList.remove('active', 'done')
if (step < n) el.classList.add('done')
if (step === n) el.classList.add('active')
})
}
const launchDemo = async () => {
if (!demoName) return
launchBtn.disabled = true
launchBtn.textContent = 'Launching...'
goToModalStep(3)
setProgressStep(1)
try {
const res = await fetch('/api/demo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: demoName, color: demoColor })
})
const data = await res.json()
if (res.ok && data.url) {
setProgressStep(2)
await sleep(150)
setProgressStep(3)
await sleep(150)
setProgressStep(4)
await sleep(150)
goToModalStep(4)
successUrl.textContent = data.url
await sleep(300)
location.href = data.url
} else {
goToModalStep(2)
launchBtn.disabled = false
launchBtn.textContent = 'Launch Demo'
alert(data.error || 'Failed to create demo')
}
} catch {
goToModalStep(2)
launchBtn.disabled = false
launchBtn.textContent = 'Launch Demo'
alert('Error creating demo')
}
}
$('#try-demo').addEventListener('click', openDemoModal)
$('#try-demo-bottom').addEventListener('click', openDemoModal)
backdrop.addEventListener('click', () => {
if (!launchBtn.disabled || modal.querySelector('.demo-step[data-step="1"].active') || modal.querySelector('.demo-step[data-step="2"].active')) {
modal.classList.remove('active')
resetModal()
}
})
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('active')) {
if (modal.querySelector('.demo-step[data-step="1"].active') || modal.querySelector('.demo-step[data-step="2"].active')) {
modal.classList.remove('active')
resetModal()
}
}
if (e.key === 'Enter' && modal.classList.contains('active')) {
if (modal.querySelector('.demo-step[data-step="1"].active') && !nextBtn.disabled) goToModalStep(2)
else if (modal.querySelector('.demo-step[data-step="2"].active')) launchDemo()
}
})
nameInput.addEventListener('input', () => {
demoName = nameInput.value.trim()
nextBtn.disabled = !demoName.length
})
nextBtn.addEventListener('click', () => goToModalStep(2))
backBtn.addEventListener('click', () => goToModalStep(1))
launchBtn.addEventListener('click', launchDemo)
$('#demo-skip').addEventListener('click', () => {
demoName = 'Demo User'
launchDemo()
})
colorSwatches.forEach(swatch => {
swatch.addEventListener('click', () => {
colorSwatches.forEach(s => s.classList.remove('selected'))
swatch.classList.add('selected')
demoColor = swatch.dataset.color
})
})
$$('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', e => {
e.preventDefault()
document.querySelector(anchor.getAttribute('href'))?.scrollIntoView({ behavior: 'smooth' })
})
})
;(() => {
const settings = { pixelSize: 8, gridSize: 4, speed: 0.05, colorShift: 0.6, colors: { bright: [0.063, 0.725, 0.506], mid: [0.047, 0.545, 0.38], dark: [0.031, 0.363, 0.253], bg: [0.2, 0.2, 0.2] } }
const canvas = $('#dither-canvas')
if (!canvas) return
const gl = canvas.getContext('webgl2')
if (!gl) return
const vs = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() { v_uv = a_position * 0.5 + 0.5; gl_Position = vec4(a_position, 0.0, 1.0); }`
const fs = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time, u_pixelSize, u_gridSize, u_speed, u_colorShift;
uniform vec3 u_color1, u_color2, u_color3, u_bgColor;
float bayer8(vec2 p) { ivec2 P = ivec2(mod(floor(p), 8.0)); int i = P.x + P.y * 8; int b[64] = int[64](0,32,8,40,2,34,10,42,48,16,56,24,50,18,58,26,12,44,4,36,14,46,6,38,60,28,52,20,62,30,54,22,3,35,11,43,1,33,9,41,51,19,59,27,49,17,57,25,15,47,7,39,13,45,5,37,63,31,55,23,61,29,53,21); return float(b[i]) / 64.0; }
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y); }
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; } return v; }
void main() {
vec2 pixelUV = floor(v_uv * u_resolution / u_pixelSize) * u_pixelSize / u_resolution;
vec2 p = pixelUV * 3.0; float t = u_time * u_speed;
float pattern = fbm(p + vec2(t * 0.5, t * 0.3)) * 0.5 + fbm(p * 1.5 - vec2(t * 0.4, -t * 0.2)) * 0.3 + fbm(p * 0.5 + vec2(-t * 0.3, t * 0.5)) * 0.2 + 0.1 * sin(p.x * 2.0 + t) * sin(p.y * 2.0 - t * 0.7);
float luma = clamp(smoothstep(0.1, 0.9, pow(pattern, 0.7)) + u_colorShift * sin(u_time * 0.3) * 0.3, 0.0, 1.0);
float threshold = bayer8(floor(v_uv * u_resolution / u_gridSize));
float level = luma * 3.0; int band = int(floor(level)); float frac = fract(level);
vec3 result = band >= 2 ? (frac > threshold ? u_color1 : u_color2) : band == 1 ? (frac > threshold ? u_color2 : u_color3) : (frac > threshold ? u_color3 : u_bgColor);
fragColor = vec4(result, 1.0);
}`
const createShader = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null }
const vertexShader = createShader(gl.VERTEX_SHADER, vs)
const fragmentShader = createShader(gl.FRAGMENT_SHADER, fs)
const program = gl.createProgram()
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return
const vao = gl.createVertexArray()
gl.bindVertexArray(vao)
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW)
const posLoc = gl.getAttribLocation(program, 'a_position')
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)
const u = { resolution: gl.getUniformLocation(program, 'u_resolution'), time: gl.getUniformLocation(program, 'u_time'), pixelSize: gl.getUniformLocation(program, 'u_pixelSize'), gridSize: gl.getUniformLocation(program, 'u_gridSize'), speed: gl.getUniformLocation(program, 'u_speed'), colorShift: gl.getUniformLocation(program, 'u_colorShift'), color1: gl.getUniformLocation(program, 'u_color1'), color2: gl.getUniformLocation(program, 'u_color2'), color3: gl.getUniformLocation(program, 'u_color3'), bgColor: gl.getUniformLocation(program, 'u_bgColor') }
gl.useProgram(program)
gl.uniform3fv(u.color1, settings.colors.bright)
gl.uniform3fv(u.color2, settings.colors.mid)
gl.uniform3fv(u.color3, settings.colors.dark)
gl.uniform3fv(u.bgColor, settings.colors.bg)
const resize = () => { const dpr = Math.min(devicePixelRatio, 2); canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr }
resize()
addEventListener('resize', resize)
const render = time => {
time *= 0.001
gl.viewport(0, 0, canvas.width, canvas.height)
gl.useProgram(program)
gl.bindVertexArray(vao)
gl.uniform2f(u.resolution, canvas.width, canvas.height)
gl.uniform1f(u.time, time)
gl.uniform1f(u.pixelSize, settings.pixelSize)
gl.uniform1f(u.gridSize, settings.gridSize)
gl.uniform1f(u.speed, settings.speed)
gl.uniform1f(u.colorShift, settings.colorShift)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
requestAnimationFrame(render)
}
requestAnimationFrame(render)
})()
</script>
</body>
</html>

View file

@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sign Up — WriteKit</title>
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico"/>
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg"/>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a0a;--bg-elevated:#111111;--bg-subtle:#1a1a1a;--text:#fafafa;--text-muted:#737373;--text-dim:#525252;--border:#262626;--border-focus:#404040;--accent:{{ACCENT}};--emerald:#10b981;--cyan:#06b6d4;--red:#ef4444}
html,body{height:100%}
body{font-family:'SF Mono','Fira Code','Consolas',monospace;background:var(--bg);color:var(--text);line-height:1.6;overflow:hidden}
.layout{display:grid;grid-template-columns:280px 1fr;height:100vh}
.sidebar{background:var(--bg);border-right:1px solid var(--border);padding:2.5rem 2rem;display:flex;flex-direction:column}
.sidebar-header{margin-bottom:3rem}
.logo{font-size:15px;font-weight:600;letter-spacing:-0.02em;margin-bottom:0.35rem}
.tagline{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.1em}
.sidebar-content{flex:1;display:flex;flex-direction:column;justify-content:center}
.step-indicator{display:flex;flex-direction:column;gap:1rem}
.step-item{display:flex;align-items:center;gap:1rem;font-size:12px;color:var(--text-dim);transition:all 0.4s ease}
.step-item.active{color:var(--text)}
.step-item.completed{color:var(--emerald)}
.step-dot{width:8px;height:8px;border:1px solid var(--border);background:transparent;transition:all 0.4s ease}
.step-item.active .step-dot{background:var(--text);border-color:var(--text);box-shadow:0 0 12px rgba(250,250,250,0.3)}
.step-item.completed .step-dot{background:var(--emerald);border-color:var(--emerald)}
.sidebar-footer{margin-top:auto}
.env-badge{display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--accent);font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--accent)}
.main{display:flex;align-items:center;justify-content:center;padding:2rem;position:relative;overflow:hidden}
.main::before{content:'';position:absolute;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:60px 60px;opacity:0.3;mask-image:radial-gradient(ellipse at center,black 0%,transparent 70%)}
.step-container{position:relative;width:100%;max-width:480px;z-index:1}
.step{position:absolute;width:100%;opacity:0;visibility:hidden;transform:translateY(30px);transition:all 0.5s cubic-bezier(0.16,1,0.3,1)}
.step.active{position:relative;opacity:1;visibility:visible;transform:translateY(0)}
.step.exit-up{transform:translateY(-30px)}
.step-header{margin-bottom:2.5rem}
.step-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem}
.step-label::before{content:'>';color:var(--emerald)}
.step-title{font-size:clamp(1.75rem,3vw,2.25rem);font-weight:500;letter-spacing:-0.03em;line-height:1.2;margin-bottom:0.75rem}
.step-desc{font-size:13px;color:var(--text-muted);line-height:1.7}
.auth-buttons{display:flex;flex-direction:column;gap:0.75rem}
.auth-btn{display:flex;align-items:center;justify-content:center;gap:0.75rem;padding:1rem 1.5rem;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text);font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:all 0.2s ease;text-decoration:none}
.auth-btn:hover{border-color:var(--border-focus);background:var(--bg-subtle)}
.auth-btn.primary{background:var(--text);color:var(--bg);border-color:var(--text)}
.auth-btn.primary:hover{background:#e5e5e5;border-color:#e5e5e5;transform:translateY(-2px);box-shadow:0 8px 24px rgba(250,250,250,0.15)}
.auth-btn svg{width:20px;height:20px;flex-shrink:0}
.auth-divider{display:flex;align-items:center;gap:1rem;margin:0.5rem 0}
.auth-divider::before,.auth-divider::after{content:'';flex:1;height:1px;background:var(--border)}
.auth-divider span{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em}
.user-greeting{display:flex;flex-direction:column}
.user-avatar{width:64px;height:64px;border-radius:50%;margin-bottom:1.5rem;border:2px solid var(--border);display:none;object-fit:cover}
.user-avatar.loaded{display:block;animation:avatarPop 0.5s cubic-bezier(0.34,1.56,0.64,1)}
@keyframes avatarPop{0%{transform:scale(0);opacity:0}100%{transform:scale(1);opacity:1}}
.subdomain-form{margin-bottom:1.5rem}
.input-row{display:flex;align-items:stretch;border:1px solid var(--border);background:var(--bg-elevated);transition:all 0.2s ease}
.input-row:focus-within{border-color:var(--border-focus)}
.input-row.valid{border-color:var(--emerald)}
.input-row.invalid{border-color:var(--red)}
.subdomain-input{flex:1;padding:1rem 1.25rem;background:transparent;border:none;color:var(--text);font-family:inherit;font-size:15px;outline:none}
.subdomain-input::placeholder{color:var(--text-dim)}
.subdomain-suffix{padding:1rem 1.25rem;background:var(--bg-subtle);color:var(--text-muted);font-size:15px;display:flex;align-items:center;border-left:1px solid var(--border)}
.input-status{height:1.5rem;margin-top:0.75rem;font-size:12px;display:flex;align-items:center;gap:0.5rem}
.input-status.available{color:var(--emerald)}
.input-status.unavailable{color:var(--red)}
.input-status.checking{color:var(--text-muted)}
.input-status .dot{width:6px;height:6px;background:currentColor;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
.btn-row{display:flex;gap:0.75rem}
.btn{padding:1rem 2rem;font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:all 0.2s ease;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text)}
.btn:hover:not(:disabled){border-color:var(--border-focus);background:var(--bg-subtle)}
.btn:disabled{opacity:0.4;cursor:not-allowed}
.btn.primary{flex:1;background:linear-gradient(135deg,var(--emerald),var(--cyan));border:none;color:white}
.btn.primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 24px rgba(16,185,129,0.3)}
.btn.primary:disabled{background:var(--border);transform:none;box-shadow:none}
.btn-back{width:48px;padding:1rem;display:flex;align-items:center;justify-content:center}
.btn-back svg{width:16px;height:16px}
.progress-steps{background:var(--bg-elevated);border:1px solid var(--border);padding:1.5rem}
.progress-step{display:flex;align-items:center;gap:1rem;padding:0.75rem 0;font-size:13px;color:var(--text-dim);transition:all 0.4s ease}
.progress-step.active{color:var(--text)}
.progress-step.done{color:var(--emerald)}
.progress-step .dot{width:8px;height:8px;background:var(--border);flex-shrink:0;transition:all 0.3s ease}
.progress-step.active .dot{background:var(--text);animation:progressPulse 1s infinite}
.progress-step.done .dot{background:var(--emerald)}
@keyframes progressPulse{0%,100%{transform:scale(1);box-shadow:0 0 0 0 rgba(250,250,250,0.4)}50%{transform:scale(1.2);box-shadow:0 0 12px 2px rgba(250,250,250,0.2)}}
.success-content{text-align:center}
.success-icon{width:72px;height:72px;margin:0 auto 2rem;background:linear-gradient(135deg,var(--emerald),var(--cyan));display:flex;align-items:center;justify-content:center;animation:successPop 0.6s cubic-bezier(0.34,1.56,0.64,1)}
@keyframes successPop{0%{transform:scale(0);opacity:0}50%{transform:scale(1.1)}100%{transform:scale(1);opacity:1}}
.success-icon svg{width:32px;height:32px;color:white;animation:checkDraw 0.4s 0.3s ease-out both}
@keyframes checkDraw{0%{stroke-dashoffset:24;opacity:0}100%{stroke-dashoffset:0;opacity:1}}
.success-icon svg path{stroke-dasharray:24;stroke-dashoffset:24;animation:checkDraw 0.4s 0.3s ease-out forwards}
.success-url{font-size:18px;font-weight:600;margin-bottom:0.5rem;background:linear-gradient(135deg,var(--emerald),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.success-redirect{font-size:12px;color:var(--text-muted);display:flex;align-items:center;justify-content:center;gap:0.5rem}
.success-redirect .dot{width:6px;height:6px;background:var(--text-muted);animation:pulse 1s infinite}
.back-link{position:absolute;top:2rem;left:2rem;font-size:12px;color:var(--text-muted);text-decoration:none;display:flex;align-items:center;gap:0.5rem;transition:color 0.2s;z-index:10}
.back-link:hover{color:var(--text)}
.back-link svg{width:14px;height:14px}
.keyboard-hint{position:absolute;bottom:2rem;left:50%;transform:translateX(-50%);font-size:11px;color:var(--text-dim);display:flex;align-items:center;gap:0.5rem}
.key{padding:0.25rem 0.5rem;background:var(--bg-subtle);border:1px solid var(--border);font-size:10px}
.color-picker{margin-bottom:2rem}
.color-options{display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1.5rem}
.color-option{width:48px;height:48px;border:2px solid transparent;background:var(--color);cursor:pointer;transition:all 0.2s ease;position:relative}
.color-option:hover{transform:scale(1.1)}
.color-option.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--color)}
.color-option.selected::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:white;font-size:18px;text-shadow:0 1px 2px rgba(0,0,0,0.3)}
.color-preview{display:flex;align-items:center;gap:1rem}
.preview-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em}
.preview-box{flex:1;height:8px;background:var(--bg-subtle);border:1px solid var(--border);overflow:hidden}
.preview-accent{height:100%;width:60%;background:var(--emerald);transition:background 0.3s ease,width 0.3s ease}
@media(max-width:900px){.layout{grid-template-columns:1fr}.sidebar{display:none}.main{padding:1.5rem}.back-link{position:relative;top:auto;left:auto;margin-bottom:2rem}.keyboard-hint{display:none}}
@media(max-width:480px){.step-title{font-size:1.5rem}.auth-btn{padding:0.875rem 1.25rem}}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">WriteKit</div>
<div class="tagline">Blogging Platform</div>
</div>
<div class="sidebar-content">
<div class="step-indicator">
<div class="step-item active" data-step="1"><span class="step-dot"></span><span>Sign in</span></div>
<div class="step-item" data-step="2"><span class="step-dot"></span><span>Personalize</span></div>
<div class="step-item" data-step="3"><span class="step-dot"></span><span>Choose subdomain</span></div>
<div class="step-item" data-step="4"><span class="step-dot"></span><span>Launch</span></div>
</div>
</div>
<div class="sidebar-footer">
<div class="env-badge">ALPHA</div>
</div>
</aside>
<main class="main">
<a href="/" class="back-link">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
Back to home
</a>
<div class="step-container">
<div class="step active" id="step-auth">
<div class="step-header">
<div class="step-label">Step 1</div>
<h1 class="step-title">Start your blog</h1>
<p class="step-desc">Sign in to create your WriteKit instance. Your blog will be ready in seconds.</p>
</div>
<div class="auth-buttons">
<a href="/auth/github?callback=/signup/complete" class="auth-btn primary">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
Continue with GitHub
</a>
<div class="auth-divider"><span>or</span></div>
<a href="/auth/google?callback=/signup/complete" class="auth-btn">
<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Continue with Google
</a>
<a href="/auth/discord?callback=/signup/complete" class="auth-btn">
<svg viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
Continue with Discord
</a>
</div>
</div>
<div class="step" id="step-personalize">
<div class="step-header">
<div class="user-greeting">
<img class="user-avatar" id="personalize-avatar" alt=""/>
<div class="step-label">Step 2</div>
<h1 class="step-title" id="personalize-title">Pick your style</h1>
</div>
<p class="step-desc">Choose an accent color for your blog. You can change this anytime in settings.</p>
</div>
<div class="color-picker">
<div class="color-options">
<button type="button" class="color-option" data-color="#10b981" style="--color:#10b981" title="Emerald"></button>
<button type="button" class="color-option" data-color="#06b6d4" style="--color:#06b6d4" title="Cyan"></button>
<button type="button" class="color-option" data-color="#8b5cf6" style="--color:#8b5cf6" title="Violet"></button>
<button type="button" class="color-option" data-color="#ec4899" style="--color:#ec4899" title="Pink"></button>
<button type="button" class="color-option" data-color="#f97316" style="--color:#f97316" title="Orange"></button>
<button type="button" class="color-option" data-color="#eab308" style="--color:#eab308" title="Yellow"></button>
<button type="button" class="color-option" data-color="#ef4444" style="--color:#ef4444" title="Red"></button>
<button type="button" class="color-option" data-color="#64748b" style="--color:#64748b" title="Slate"></button>
</div>
<div class="color-preview">
<span class="preview-label">Preview</span>
<div class="preview-box" id="color-preview"><div class="preview-accent"></div></div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-back" id="btn-personalize-back" type="button" title="Use different account">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
</button>
<button class="btn primary" id="btn-personalize-next">Continue</button>
</div>
</div>
<div class="step" id="step-subdomain">
<div class="step-header">
<div class="user-greeting" id="user-greeting">
<img class="user-avatar" id="user-avatar" alt=""/>
<div class="step-label">Step 3</div>
<h1 class="step-title" id="greeting-title">Choose your subdomain</h1>
</div>
<p class="step-desc">Pick a memorable address for your blog. You can add a custom domain later.</p>
</div>
<div class="subdomain-form">
<div class="input-row" id="input-row">
<input type="text" class="subdomain-input" id="subdomain" placeholder="myblog" autocomplete="off" spellcheck="false" autofocus/>
<span class="subdomain-suffix" id="domain-suffix">.writekit.dev</span>
</div>
<div class="input-status" id="status"></div>
</div>
<div class="btn-row">
<button class="btn btn-back" id="btn-back" type="button">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
</button>
<button class="btn primary" id="btn-launch" disabled>Create my blog</button>
</div>
</div>
<div class="step" id="step-provisioning">
<div class="step-header">
<div class="step-label">Step 4</div>
<h1 class="step-title">Launching your blog</h1>
<p class="step-desc">Setting everything up. This only takes a few seconds.</p>
</div>
<div class="progress-steps" id="progress-steps">
<div class="progress-step" data-step="1"><span class="dot"></span><span>Reserving subdomain...</span></div>
<div class="progress-step" data-step="2"><span class="dot"></span><span>Spinning up container...</span></div>
<div class="progress-step" data-step="3"><span class="dot"></span><span>Configuring SSL...</span></div>
<div class="progress-step" data-step="4"><span class="dot"></span><span>Almost ready...</span></div>
</div>
</div>
<div class="step" id="step-success">
<div class="success-content">
<div class="success-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<div class="step-title">You're all set!</div>
<div class="success-url" id="success-url"></div>
<div class="success-redirect"><span class="dot"></span><span>Redirecting to your studio...</span></div>
</div>
</div>
</div>
<div class="keyboard-hint">Press <span class="key">Enter</span> to continue</div>
</main>
</div>
<script>
const $ = s => document.getElementById(s)
const $$ = s => document.querySelectorAll(s)
const steps = { auth: $('step-auth'), personalize: $('step-personalize'), subdomain: $('step-subdomain'), provisioning: $('step-provisioning'), success: $('step-success') }
const stepIndicators = $$('.step-item')
const subdomainInput = $('subdomain')
const inputRow = $('input-row')
const status = $('status')
const btnLaunch = $('btn-launch')
const btnBack = $('btn-back')
const successUrl = $('success-url')
const userAvatar = $('user-avatar')
const greetingTitle = $('greeting-title')
const personalizeAvatar = $('personalize-avatar')
const personalizeTitle = $('personalize-title')
const colorOptions = $$('.color-option')
const previewAccent = document.querySelector('.preview-accent')
const btnPersonalizeNext = $('btn-personalize-next')
const btnPersonalizeBack = $('btn-personalize-back')
let currentStep = 'auth'
let currentSubdomain = ''
let isAvailable = false
let debounceTimer
let currentUser = null
let selectedColor = '#10b981'
const sleep = ms => new Promise(r => setTimeout(r, ms))
const fetchUserInfo = async token => {
try {
const res = await fetch(`/api/auth/user?token=${encodeURIComponent(token)}`)
if (!res.ok) {
sessionStorage.removeItem('signup_token')
return false
}
const user = await res.json()
currentUser = user
const firstName = user.name?.split(' ')[0] ?? ''
if (user.avatar_url) {
userAvatar.src = user.avatar_url
userAvatar.onload = () => userAvatar.classList.add('loaded')
personalizeAvatar.src = user.avatar_url
personalizeAvatar.onload = () => personalizeAvatar.classList.add('loaded')
}
if (firstName) {
greetingTitle.textContent = `Hey ${firstName}!`
personalizeTitle.textContent = `Pick your style, ${firstName}`
}
return true
} catch {
sessionStorage.removeItem('signup_token')
return false
}
}
const goToStep = stepName => {
const currentEl = steps[currentStep]
const nextEl = steps[stepName]
const stepOrder = ['auth', 'personalize', 'subdomain', 'provisioning', 'success']
const nextIndex = stepOrder.indexOf(stepName)
stepIndicators.forEach((indicator, i) => {
indicator.classList.remove('active', 'completed')
if (i < nextIndex) indicator.classList.add('completed')
else if (i === nextIndex) indicator.classList.add('active')
})
currentEl.classList.add('exit-up')
currentEl.classList.remove('active')
setTimeout(() => {
currentEl.classList.remove('exit-up')
nextEl.classList.add('active')
currentStep = stepName
if (stepName === 'subdomain') setTimeout(() => subdomainInput.focus(), 100)
}, 150)
}
const checkAvailability = async subdomain => {
if (subdomain !== currentSubdomain) return
try {
const res = await fetch(`/api/demo/check?subdomain=${encodeURIComponent(subdomain)}`)
const data = await res.json()
if (subdomain !== currentSubdomain) return
if (data.domain) $('domain-suffix').textContent = '.' + data.domain
if (data.available) {
status.textContent = `${subdomain}.${data.domain} is available`
status.className = 'input-status available'
inputRow.className = 'input-row valid'
btnLaunch.disabled = false
isAvailable = true
} else {
status.textContent = data.reason || 'Not available'
status.className = 'input-status unavailable'
inputRow.className = 'input-row invalid'
btnLaunch.disabled = true
isAvailable = false
}
} catch {
status.textContent = 'Error checking availability'
status.className = 'input-status unavailable'
btnLaunch.disabled = true
isAvailable = false
}
}
const animateProgress = async url => {
const progressSteps = $$('#progress-steps .progress-step')
let ready = false
let redirecting = false
const pollUrl = async () => {
const start = Date.now()
while (!ready && Date.now() - start < 30000) {
try {
const res = await fetch(url + '/health', { method: 'HEAD' })
if (res.ok || res.status === 307 || res.status === 302) ready = true
} catch { await sleep(300) }
}
}
const doRedirect = () => {
if (redirecting) return
redirecting = true
progressSteps.forEach(s => { s.classList.remove('active'); s.classList.add('done') })
successUrl.textContent = url.replace('https://', '')
goToStep('success')
const token = sessionStorage.getItem('signup_token')
setTimeout(() => { window.location.href = url + '/auth/callback?token=' + encodeURIComponent(token) + '&redirect=/studio' }, 400)
}
pollUrl().then(() => { if (ready) doRedirect() })
for (let i = 0; i < progressSteps.length; i++) {
if (ready) return doRedirect()
progressSteps[i].classList.add('active')
await sleep(300)
progressSteps[i].classList.remove('active')
progressSteps[i].classList.add('done')
}
while (!ready) await sleep(100)
doRedirect()
}
const launchBlog = async () => {
if (!isAvailable || !currentSubdomain) return
const token = sessionStorage.getItem('signup_token')
if (!token) { goToStep('auth'); return }
btnLaunch.disabled = true
goToStep('provisioning')
try {
const res = await fetch('/api/signup/tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ subdomain: currentSubdomain, accent_color: selectedColor })
})
if (res.status === 401) { sessionStorage.removeItem('signup_token'); goToStep('auth'); return }
const data = await res.json()
if (res.ok && data.url) {
await animateProgress(data.url)
} else {
goToStep('subdomain')
status.textContent = data.error || 'Failed to create blog'
status.className = 'input-status unavailable'
}
} catch {
goToStep('subdomain')
status.textContent = 'Error creating blog'
status.className = 'input-status unavailable'
}
}
;(async () => {
const urlParams = new URLSearchParams(window.location.search)
const urlToken = urlParams.get('token')
const storedToken = sessionStorage.getItem('signup_token')
if (urlToken) {
sessionStorage.setItem('signup_token', urlToken)
window.history.replaceState({}, '', '/signup')
if (await fetchUserInfo(urlToken)) goToStep('personalize')
} else if (storedToken) {
if (await fetchUserInfo(storedToken)) goToStep('personalize')
}
colorOptions[0]?.classList.add('selected')
})()
subdomainInput.addEventListener('input', () => {
const value = subdomainInput.value.toLowerCase().replace(/[^a-z0-9-]/g, '')
if (value !== subdomainInput.value) subdomainInput.value = value
currentSubdomain = value
if (!value) {
status.textContent = ''
status.className = 'input-status'
inputRow.className = 'input-row'
btnLaunch.disabled = true
isAvailable = false
return
}
clearTimeout(debounceTimer)
status.innerHTML = '<span class="dot"></span> Checking...'
status.className = 'input-status checking'
inputRow.className = 'input-row'
btnLaunch.disabled = true
debounceTimer = setTimeout(() => checkAvailability(value), 300)
})
colorOptions.forEach(option => {
option.addEventListener('click', () => {
colorOptions.forEach(o => o.classList.remove('selected'))
option.classList.add('selected')
selectedColor = option.dataset.color
previewAccent.style.background = selectedColor
})
})
btnPersonalizeBack.addEventListener('click', () => { sessionStorage.removeItem('signup_token'); goToStep('auth') })
btnPersonalizeNext.addEventListener('click', () => goToStep('subdomain'))
btnBack.addEventListener('click', () => goToStep('personalize'))
btnLaunch.addEventListener('click', launchBlog)
document.addEventListener('keydown', e => { if (e.key === 'Enter' && currentStep === 'subdomain' && isAvailable) launchBlog() })
</script>
</body>
</html>

113
internal/storage/s3.go Normal file
View file

@ -0,0 +1,113 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type S3Client struct {
s3 *s3.Client
presign *s3.PresignClient
bucket string
publicURL string
}
type S3Config struct {
Endpoint string
AccessKey string
SecretKey string
Bucket string
PublicURL string
Region string
}
func NewS3Client(cfg S3Config) (*S3Client, error) {
awsCfg, err := config.LoadDefaultConfig(context.Background(),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
config.WithRegion(cfg.Region),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
}
})
return &S3Client{
s3: client,
presign: s3.NewPresignClient(client),
bucket: cfg.Bucket,
publicURL: cfg.PublicURL,
}, nil
}
func NewR2Client() (*S3Client, error) {
accountID := os.Getenv("R2_ACCOUNT_ID")
accessKey := os.Getenv("R2_ACCESS_KEY_ID")
secretKey := os.Getenv("R2_SECRET_ACCESS_KEY")
bucket := os.Getenv("R2_BUCKET")
publicURL := os.Getenv("R2_PUBLIC_URL")
if accountID == "" || accessKey == "" || secretKey == "" {
return nil, fmt.Errorf("R2 credentials not configured")
}
endpoint := os.Getenv("R2_ENDPOINT")
if endpoint == "" {
endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID)
}
return NewS3Client(S3Config{
Endpoint: endpoint,
AccessKey: accessKey,
SecretKey: secretKey,
Bucket: bucket,
PublicURL: publicURL,
Region: "auto",
})
}
func (c *S3Client) Upload(ctx context.Context, key string, body io.Reader, contentType string) error {
_, err := c.s3.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
})
return err
}
func (c *S3Client) Delete(ctx context.Context, key string) error {
_, err := c.s3.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
return err
}
func (c *S3Client) PresignUpload(ctx context.Context, key string, contentType string, expires time.Duration) (string, error) {
req, err := c.presign.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
}, s3.WithPresignExpires(expires))
if err != nil {
return "", err
}
return req.URL, nil
}
func (c *S3Client) PublicURL(key string) string {
return fmt.Sprintf("%s/%s", c.publicURL, key)
}

View file

@ -0,0 +1,14 @@
package storage
import (
"context"
"io"
"time"
)
type Client interface {
Upload(ctx context.Context, key string, body io.Reader, contentType string) error
Delete(ctx context.Context, key string) error
PresignUpload(ctx context.Context, key string, contentType string, expires time.Duration) (string, error)
PublicURL(key string) string
}

View file

@ -0,0 +1,279 @@
package tenant
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type PageView struct {
ID int64
Path string
PostSlug string
Referrer string
UserAgent string
CreatedAt time.Time
}
type AnalyticsSummary struct {
TotalViews int64 `json:"total_views"`
TotalPageViews int64 `json:"total_page_views"`
UniqueVisitors int64 `json:"unique_visitors"`
TotalBandwidth int64 `json:"total_bandwidth"`
ViewsChange float64 `json:"views_change"`
TopPages []PageStats `json:"top_pages"`
TopReferrers []ReferrerStats `json:"top_referrers"`
ViewsByDay []DailyStats `json:"views_by_day"`
Browsers []NamedStat `json:"browsers"`
OS []NamedStat `json:"os"`
Devices []NamedStat `json:"devices"`
Countries []NamedStat `json:"countries"`
}
type PageStats struct {
Path string `json:"path"`
Views int64 `json:"views"`
}
type ReferrerStats struct {
Referrer string `json:"referrer"`
Views int64 `json:"views"`
}
type DailyStats struct {
Date string `json:"date"`
Views int64 `json:"views"`
Visitors int64 `json:"visitors"`
}
type NamedStat struct {
Name string `json:"name"`
Count int64 `json:"count"`
}
type ArchivedDay struct {
Date string `json:"date"`
Requests int64 `json:"requests"`
PageViews int64 `json:"page_views"`
UniqueVisitors int64 `json:"unique_visitors"`
Bandwidth int64 `json:"bandwidth"`
Browsers []NamedStat `json:"browsers"`
OS []NamedStat `json:"os"`
Devices []NamedStat `json:"devices"`
Countries []NamedStat `json:"countries"`
Paths []PageStats `json:"paths"`
}
func (q *Queries) RecordPageView(ctx context.Context, path, postSlug, referrer, userAgent string) error {
_, err := q.db.ExecContext(ctx, `INSERT INTO page_views (path, post_slug, referrer, user_agent, visitor_hash, utm_source, utm_medium, utm_campaign, device_type, browser, os, country)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
path, nullStr(postSlug), nullStr(referrer), nullStr(userAgent), sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{})
return err
}
func (q *Queries) GetAnalytics(ctx context.Context, days int) (*AnalyticsSummary, error) {
if days <= 0 {
days = 30
}
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
var totalCount, uniqueCount int64
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*), COUNT(DISTINCT visitor_hash) FROM page_views WHERE created_at >= ?`, since).
Scan(&totalCount, &uniqueCount)
if err != nil {
return nil, err
}
topPagesRows, err := q.db.QueryContext(ctx, `SELECT path, COUNT(*) as views FROM page_views WHERE created_at >= ? GROUP BY path ORDER BY views DESC LIMIT ?`, since, 10)
if err != nil {
return nil, err
}
defer topPagesRows.Close()
var topPages []PageStats
for topPagesRows.Next() {
var p PageStats
if err := topPagesRows.Scan(&p.Path, &p.Views); err != nil {
return nil, err
}
topPages = append(topPages, p)
}
topRefRows, err := q.db.QueryContext(ctx, `SELECT COALESCE(referrer, 'Direct') as referrer, COUNT(*) as views FROM page_views WHERE created_at >= ? AND referrer != '' GROUP BY referrer ORDER BY views DESC LIMIT ?`, since, 10)
if err != nil {
return nil, err
}
defer topRefRows.Close()
var topReferrers []ReferrerStats
for topRefRows.Next() {
var r ReferrerStats
if err := topRefRows.Scan(&r.Referrer, &r.Views); err != nil {
return nil, err
}
topReferrers = append(topReferrers, r)
}
viewsByDayRows, err := q.db.QueryContext(ctx, `SELECT DATE(created_at) as date, COUNT(*) as views FROM page_views WHERE created_at >= ? GROUP BY date ORDER BY date ASC`, since)
if err != nil {
return nil, err
}
defer viewsByDayRows.Close()
var viewsByDay []DailyStats
for viewsByDayRows.Next() {
var d DailyStats
var date any
if err := viewsByDayRows.Scan(&date, &d.Views); err != nil {
return nil, err
}
if s, ok := date.(string); ok {
d.Date = s
}
viewsByDay = append(viewsByDay, d)
}
return &AnalyticsSummary{
TotalViews: totalCount,
TotalPageViews: totalCount,
UniqueVisitors: uniqueCount,
TopPages: topPages,
TopReferrers: topReferrers,
ViewsByDay: viewsByDay,
}, nil
}
func (q *Queries) GetPostAnalytics(ctx context.Context, slug string, days int) (*AnalyticsSummary, error) {
if days <= 0 {
days = 30
}
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
var totalViews int64
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM page_views WHERE post_slug = ? AND created_at >= ?`, slug, since).Scan(&totalViews)
if err != nil {
return nil, err
}
viewsByDayRows, err := q.db.QueryContext(ctx, `SELECT DATE(created_at) as date, COUNT(*) as views FROM page_views WHERE post_slug = ? AND created_at >= ? GROUP BY date ORDER BY date ASC`, slug, since)
if err != nil {
return nil, err
}
defer viewsByDayRows.Close()
var viewsByDay []DailyStats
for viewsByDayRows.Next() {
var d DailyStats
var date any
if err := viewsByDayRows.Scan(&date, &d.Views); err != nil {
return nil, err
}
if s, ok := date.(string); ok {
d.Date = s
}
viewsByDay = append(viewsByDay, d)
}
refRows, err := q.db.QueryContext(ctx, `SELECT COALESCE(referrer, 'Direct') as referrer, COUNT(*) as views FROM page_views WHERE post_slug = ? AND created_at >= ? AND referrer != '' GROUP BY referrer ORDER BY views DESC LIMIT ?`, slug, since, 10)
if err != nil {
return nil, err
}
defer refRows.Close()
var topReferrers []ReferrerStats
for refRows.Next() {
var r ReferrerStats
if err := refRows.Scan(&r.Referrer, &r.Views); err != nil {
return nil, err
}
topReferrers = append(topReferrers, r)
}
return &AnalyticsSummary{
TotalViews: totalViews,
TopReferrers: topReferrers,
ViewsByDay: viewsByDay,
}, nil
}
func (q *Queries) SaveDailyAnalytics(ctx context.Context, day *ArchivedDay) error {
browsers, _ := json.Marshal(day.Browsers)
os, _ := json.Marshal(day.OS)
devices, _ := json.Marshal(day.Devices)
countries, _ := json.Marshal(day.Countries)
paths, _ := json.Marshal(day.Paths)
_, err := q.db.ExecContext(ctx, `INSERT INTO daily_analytics (date, requests, page_views, unique_visitors, bandwidth, browsers, os, devices, countries, paths)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(date) DO UPDATE SET
requests = excluded.requests, page_views = excluded.page_views, unique_visitors = excluded.unique_visitors,
bandwidth = excluded.bandwidth, browsers = excluded.browsers, os = excluded.os, devices = excluded.devices,
countries = excluded.countries, paths = excluded.paths`,
day.Date, day.Requests, day.PageViews, day.UniqueVisitors, day.Bandwidth,
nullStr(string(browsers)), nullStr(string(os)), nullStr(string(devices)), nullStr(string(countries)), nullStr(string(paths)))
return err
}
func (q *Queries) GetArchivedAnalytics(ctx context.Context, since, until string) ([]ArchivedDay, error) {
rows, err := q.db.QueryContext(ctx, `SELECT date, requests, page_views, unique_visitors, bandwidth, browsers, os, devices, countries, paths
FROM daily_analytics WHERE date >= ? AND date <= ? ORDER BY date ASC`, since, until)
if err != nil {
return nil, err
}
defer rows.Close()
var days []ArchivedDay
for rows.Next() {
var d ArchivedDay
var requests, pageViews, uniqueVisitors, bandwidth sql.NullInt64
var browsers, os, devices, countries, paths sql.NullString
if err := rows.Scan(&d.Date, &requests, &pageViews, &uniqueVisitors, &bandwidth, &browsers, &os, &devices, &countries, &paths); err != nil {
return nil, err
}
d.Requests = requests.Int64
d.PageViews = pageViews.Int64
d.UniqueVisitors = uniqueVisitors.Int64
d.Bandwidth = bandwidth.Int64
if browsers.Valid {
json.Unmarshal([]byte(browsers.String), &d.Browsers)
}
if os.Valid {
json.Unmarshal([]byte(os.String), &d.OS)
}
if devices.Valid {
json.Unmarshal([]byte(devices.String), &d.Devices)
}
if countries.Valid {
json.Unmarshal([]byte(countries.String), &d.Countries)
}
if paths.Valid {
json.Unmarshal([]byte(paths.String), &d.Paths)
}
days = append(days, d)
}
return days, rows.Err()
}
func (q *Queries) GetOldestArchivedDate(ctx context.Context) (string, error) {
var date any
err := q.db.QueryRowContext(ctx, `SELECT MIN(date) FROM daily_analytics`).Scan(&date)
if err != nil || date == nil {
return "", err
}
if s, ok := date.(string); ok {
return s, nil
}
return "", nil
}
func (q *Queries) HasArchivedDate(ctx context.Context, date string) (bool, error) {
var count int64
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM daily_analytics WHERE date = ?`, date).Scan(&count)
return count > 0, err
}

View file

@ -0,0 +1,94 @@
package tenant
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"time"
)
func (q *Queries) ListAPIKeys(ctx context.Context) ([]APIKey, error) {
rows, err := q.db.QueryContext(ctx, `SELECT key, name, created_at, last_used_at FROM api_keys ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []APIKey
for rows.Next() {
k, err := scanAPIKey(rows)
if err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
func (q *Queries) CreateAPIKey(ctx context.Context, name string) (*APIKey, error) {
key, err := generateAPIKey()
if err != nil {
return nil, err
}
now := time.Now().UTC().Format(time.RFC3339)
_, err = q.db.ExecContext(ctx, `INSERT INTO api_keys (key, name, created_at) VALUES (?, ?, ?)`, key, name, now)
if err != nil {
return nil, err
}
return &APIKey{
Key: key,
Name: name,
CreatedAt: time.Now().UTC(),
}, nil
}
func (q *Queries) ValidateAPIKey(ctx context.Context, key string) (bool, error) {
var dummy int64
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM api_keys WHERE key = ?`, key).Scan(&dummy)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, nil
}
go func() {
now := time.Now().UTC().Format(time.RFC3339)
q.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE key = ?`, now, key)
}()
return true, nil
}
func (q *Queries) DeleteAPIKey(ctx context.Context, key string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM api_keys WHERE key = ?`, key)
return err
}
func scanAPIKey(s scanner) (APIKey, error) {
var k APIKey
var createdAt, lastUsedAt sql.NullString
err := s.Scan(&k.Key, &k.Name, &createdAt, &lastUsedAt)
if err != nil {
return k, err
}
k.CreatedAt = parseTime(createdAt.String)
if lastUsedAt.Valid {
t := parseTime(lastUsedAt.String)
k.LastUsedAt = &t
}
return k, nil
}
func generateAPIKey() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "wk_" + hex.EncodeToString(b), nil
}

80
internal/tenant/assets.go Normal file
View file

@ -0,0 +1,80 @@
package tenant
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
func (q *Queries) ListAssets(ctx context.Context) ([]Asset, error) {
rows, err := q.db.QueryContext(ctx, `SELECT id, filename, r2_key, content_type, size, width, height, created_at
FROM assets ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var assets []Asset
for rows.Next() {
a, err := scanAsset(rows)
if err != nil {
return nil, err
}
assets = append(assets, a)
}
return assets, rows.Err()
}
func (q *Queries) GetAsset(ctx context.Context, id string) (*Asset, error) {
row := q.db.QueryRowContext(ctx, `SELECT id, filename, r2_key, content_type, size, width, height, created_at
FROM assets WHERE id = ?`, id)
a, err := scanAsset(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &a, nil
}
func (q *Queries) CreateAsset(ctx context.Context, a *Asset) error {
if a.ID == "" {
a.ID = uuid.NewString()
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := q.db.ExecContext(ctx, `INSERT INTO assets (id, filename, r2_key, content_type, size, width, height, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.Filename, a.R2Key, nullStr(a.ContentType), nullInt64(a.Size), nullInt64(int64(a.Width)), nullInt64(int64(a.Height)), now)
return err
}
func (q *Queries) DeleteAsset(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM assets WHERE id = ?`, id)
return err
}
func scanAsset(s scanner) (Asset, error) {
var a Asset
var contentType, createdAt sql.NullString
var size, width, height sql.NullInt64
err := s.Scan(&a.ID, &a.Filename, &a.R2Key, &contentType, &size, &width, &height, &createdAt)
if err != nil {
return a, err
}
a.ContentType = contentType.String
a.Size = size.Int64
a.Width = int(width.Int64)
a.Height = int(height.Int64)
a.CreatedAt = parseTime(createdAt.String)
return a, nil
}
func nullInt64(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: v != 0}
}

View file

@ -0,0 +1,70 @@
package tenant
import (
"context"
"database/sql"
)
func (q *Queries) ListComments(ctx context.Context, postSlug string) ([]Comment, error) {
rows, err := q.db.QueryContext(ctx, `SELECT id, user_id, post_slug, content, content_html, parent_id, created_at, updated_at
FROM comments WHERE post_slug = ? ORDER BY created_at ASC`, postSlug)
if err != nil {
return nil, err
}
defer rows.Close()
var comments []Comment
for rows.Next() {
c, err := scanComment(rows)
if err != nil {
return nil, err
}
comments = append(comments, c)
}
return comments, rows.Err()
}
func (q *Queries) GetComment(ctx context.Context, id int64) (*Comment, error) {
row := q.db.QueryRowContext(ctx, `SELECT id, user_id, post_slug, content, content_html, parent_id, created_at, updated_at
FROM comments WHERE id = ?`, id)
c, err := scanComment(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
func (q *Queries) CreateComment(ctx context.Context, c *Comment) error {
result, err := q.db.ExecContext(ctx, `INSERT INTO comments (user_id, post_slug, content, content_html, parent_id)
VALUES (?, ?, ?, ?, ?)`,
c.UserID, c.PostSlug, c.Content, nullStr(c.ContentHTML), c.ParentID)
if err != nil {
return err
}
c.ID, _ = result.LastInsertId()
return nil
}
func (q *Queries) DeleteComment(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM comments WHERE id = ?`, id)
return err
}
func scanComment(s scanner) (Comment, error) {
var c Comment
var contentHTML, createdAt, updatedAt sql.NullString
err := s.Scan(&c.ID, &c.UserID, &c.PostSlug, &c.Content, &contentHTML, &c.ParentID, &createdAt, &updatedAt)
if err != nil {
return c, err
}
c.ContentHTML = contentHTML.String
c.CreatedAt = parseTime(createdAt.String)
c.UpdatedAt = parseTime(updatedAt.String)
return c, nil
}

View file

@ -0,0 +1,63 @@
package tenant
import (
"context"
"database/sql"
"time"
)
func (q *Queries) UpsertMember(ctx context.Context, m *Member) error {
_, err := q.db.ExecContext(ctx, `INSERT INTO members (user_id, email, name, tier, status, expires_at, synced_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
email = excluded.email, name = excluded.name, tier = excluded.tier,
status = excluded.status, expires_at = excluded.expires_at, synced_at = CURRENT_TIMESTAMP`,
m.UserID, m.Email, nullStr(m.Name), m.Tier, m.Status, timeToStr(m.ExpiresAt))
return err
}
func (q *Queries) GetMember(ctx context.Context, userID string) (*Member, error) {
row := q.db.QueryRowContext(ctx, `SELECT user_id, email, name, tier, status, expires_at, synced_at
FROM members WHERE user_id = ?`, userID)
m, err := scanMember(row)
if err == sql.ErrNoRows {
return nil, err
}
if err != nil {
return nil, err
}
return &m, nil
}
func (q *Queries) IsMember(ctx context.Context, userID string) bool {
var count int64
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM members
WHERE user_id = ? AND status = 'active'
AND (expires_at IS NULL OR expires_at > datetime('now'))`, userID).Scan(&count)
return err == nil && count > 0
}
func (q *Queries) DeleteMember(ctx context.Context, userID string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM members WHERE user_id = ?`, userID)
return err
}
func scanMember(s scanner) (Member, error) {
var m Member
var name, expiresAt, syncedAt sql.NullString
err := s.Scan(&m.UserID, &m.Email, &name, &m.Tier, &m.Status, &expiresAt, &syncedAt)
if err != nil {
return m, err
}
m.Name = name.String
m.SyncedAt = parseTime(syncedAt.String)
if expiresAt.Valid {
if t, err := time.Parse(time.RFC3339, expiresAt.String); err == nil {
m.ExpiresAt = &t
}
}
return m, nil
}

124
internal/tenant/models.go Normal file
View file

@ -0,0 +1,124 @@
package tenant
import "time"
type Post struct {
ID string
Slug string
Title string
Description string
Tags []string
CoverImage string
ContentMD string
ContentHTML string
IsPublished bool
MembersOnly bool
PublishedAt *time.Time
UpdatedAt *time.Time
Aliases []string
CreatedAt time.Time
ModifiedAt time.Time
}
type PostDraft struct {
PostID string
Slug string
Title string
Description string
Tags []string
CoverImage string
MembersOnly bool
ContentMD string
ContentHTML string
ModifiedAt time.Time
}
type PostVersion struct {
ID int64
PostID string
Slug string
Title string
Description string
Tags []string
CoverImage string
ContentMD string
ContentHTML string
CreatedAt time.Time
}
type Asset struct {
ID string
Filename string
R2Key string
ContentType string
Size int64
Width int
Height int
CreatedAt time.Time
}
type Settings map[string]string
type Member struct {
UserID string
Email string
Name string
Tier string
Status string
ExpiresAt *time.Time
SyncedAt time.Time
}
type Comment struct {
ID int64
UserID string
PostSlug string
Content string
ContentHTML string
ParentID *int64
CreatedAt time.Time
UpdatedAt time.Time
}
type Reaction struct {
ID int64
UserID string
AnonID string
PostSlug string
Emoji string
CreatedAt time.Time
}
type User struct {
ID string
Email string
Name string
AvatarURL string
CreatedAt time.Time
}
type Session struct {
Token string
UserID string
ExpiresAt time.Time
}
type APIKey struct {
Key string
Name string
CreatedAt time.Time
LastUsedAt *time.Time
}
type Plugin struct {
ID string
Name string
Language string
Source string
Wasm []byte
WasmSize int
Hooks []string
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}

39
internal/tenant/pages.go Normal file
View file

@ -0,0 +1,39 @@
package tenant
import (
"context"
"database/sql"
"time"
)
func (q *Queries) GetPage(ctx context.Context, path string) ([]byte, string, error) {
var html []byte
var etag sql.NullString
err := q.db.QueryRowContext(ctx, `SELECT html, etag FROM pages WHERE path = ?`, path).Scan(&html, &etag)
if err == sql.ErrNoRows {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
return html, etag.String, nil
}
func (q *Queries) SetPage(ctx context.Context, path string, html []byte, etag string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := q.db.ExecContext(ctx, `INSERT INTO pages (path, html, etag, built_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET html = excluded.html, etag = excluded.etag, built_at = excluded.built_at`,
path, html, nullStr(etag), now)
return err
}
func (q *Queries) DeleteAllPages(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM pages`)
return err
}
func (q *Queries) DeletePage(ctx context.Context, path string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM pages WHERE path = ?`, path)
return err
}

136
internal/tenant/plugins.go Normal file
View file

@ -0,0 +1,136 @@
package tenant
import (
"context"
"database/sql"
"encoding/json"
)
func (q *Queries) CountPlugins(ctx context.Context) (int, error) {
var count int
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM plugins`).Scan(&count)
return count, err
}
func (q *Queries) ListPlugins(ctx context.Context) ([]Plugin, error) {
rows, err := q.db.QueryContext(ctx, `SELECT id, name, language, source, hooks, enabled, LENGTH(wasm) as wasm_size, created_at, updated_at
FROM plugins ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var plugins []Plugin
for rows.Next() {
p, err := scanPluginList(rows)
if err != nil {
return nil, err
}
plugins = append(plugins, p)
}
return plugins, rows.Err()
}
func (q *Queries) GetPlugin(ctx context.Context, id string) (*Plugin, error) {
row := q.db.QueryRowContext(ctx, `SELECT id, name, language, source, wasm, hooks, enabled, created_at, updated_at
FROM plugins WHERE id = ?`, id)
p, err := scanPlugin(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
func (q *Queries) CreatePlugin(ctx context.Context, p *Plugin) error {
hooks, _ := json.Marshal(p.Hooks)
_, err := q.db.ExecContext(ctx, `INSERT INTO plugins (id, name, language, source, wasm, hooks, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Name, p.Language, p.Source, p.Wasm, string(hooks), boolToInt(p.Enabled))
return err
}
func (q *Queries) UpdatePlugin(ctx context.Context, p *Plugin) error {
hooks, _ := json.Marshal(p.Hooks)
_, err := q.db.ExecContext(ctx, `UPDATE plugins SET
name = ?, language = ?, source = ?, wasm = ?, hooks = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
p.Name, p.Language, p.Source, p.Wasm, string(hooks), boolToInt(p.Enabled), p.ID)
return err
}
func (q *Queries) DeletePlugin(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id)
return err
}
func (q *Queries) GetPluginsByHook(ctx context.Context, hook string) ([]Plugin, error) {
rows, err := q.db.QueryContext(ctx, `SELECT id, name, language, source, wasm, hooks, enabled, created_at, updated_at
FROM plugins WHERE enabled = 1 AND hooks LIKE ?`, "%"+hook+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var plugins []Plugin
for rows.Next() {
p, err := scanPlugin(rows)
if err != nil {
return nil, err
}
for _, h := range p.Hooks {
if h == hook {
plugins = append(plugins, p)
break
}
}
}
return plugins, rows.Err()
}
func (q *Queries) TogglePlugin(ctx context.Context, id string, enabled bool) error {
_, err := q.db.ExecContext(ctx, `UPDATE plugins SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
boolToInt(enabled), id)
return err
}
func scanPlugin(s scanner) (Plugin, error) {
var p Plugin
var hooks string
var enabled sql.NullInt64
var createdAt, updatedAt sql.NullString
err := s.Scan(&p.ID, &p.Name, &p.Language, &p.Source, &p.Wasm, &hooks, &enabled, &createdAt, &updatedAt)
if err != nil {
return p, err
}
p.Enabled = enabled.Int64 == 1
p.CreatedAt = parseTime(createdAt.String)
p.UpdatedAt = parseTime(updatedAt.String)
json.Unmarshal([]byte(hooks), &p.Hooks)
return p, nil
}
func scanPluginList(s scanner) (Plugin, error) {
var p Plugin
var hooks string
var enabled sql.NullInt64
var wasmSize sql.NullInt64
var createdAt, updatedAt sql.NullString
err := s.Scan(&p.ID, &p.Name, &p.Language, &p.Source, &hooks, &enabled, &wasmSize, &createdAt, &updatedAt)
if err != nil {
return p, err
}
p.Enabled = enabled.Int64 == 1
p.WasmSize = int(wasmSize.Int64)
p.CreatedAt = parseTime(createdAt.String)
p.UpdatedAt = parseTime(updatedAt.String)
json.Unmarshal([]byte(hooks), &p.Hooks)
return p, nil
}

176
internal/tenant/pool.go Normal file
View file

@ -0,0 +1,176 @@
package tenant
import (
"container/list"
"database/sql"
"sync"
"time"
)
const (
maxOpenConns = 500
cacheTTL = 5 * time.Minute
cacheCleanupFreq = time.Minute
)
type conn struct {
db *sql.DB
tenantID string
}
// Pool manages SQLite connections for tenants with LRU eviction.
type Pool struct {
dataDir string
mu sync.Mutex
conns map[string]*list.Element
lru *list.List
inMemory map[string]bool
}
func NewPool(dataDir string) *Pool {
return &Pool{
dataDir: dataDir,
conns: make(map[string]*list.Element),
lru: list.New(),
inMemory: make(map[string]bool),
}
}
func (p *Pool) MarkAsDemo(tenantID string) {
p.mu.Lock()
defer p.mu.Unlock()
p.inMemory[tenantID] = true
}
func (p *Pool) Get(tenantID string) (*sql.DB, error) {
p.mu.Lock()
defer p.mu.Unlock()
if elem, ok := p.conns[tenantID]; ok {
p.lru.MoveToFront(elem)
return elem.Value.(*conn).db, nil
}
useInMemory := p.inMemory[tenantID]
db, err := openDB(p.dataDir, tenantID, useInMemory)
if err != nil {
return nil, err
}
for p.lru.Len() >= maxOpenConns {
p.evictOldest()
}
c := &conn{db: db, tenantID: tenantID}
elem := p.lru.PushFront(c)
p.conns[tenantID] = elem
return db, nil
}
func (p *Pool) evictOldest() {
elem := p.lru.Back()
if elem == nil {
return
}
c := elem.Value.(*conn)
c.db.Close()
delete(p.conns, c.tenantID)
delete(p.inMemory, c.tenantID)
p.lru.Remove(elem)
}
func (p *Pool) Evict(tenantID string) {
p.mu.Lock()
defer p.mu.Unlock()
if elem, ok := p.conns[tenantID]; ok {
c := elem.Value.(*conn)
c.db.Close()
delete(p.conns, tenantID)
p.lru.Remove(elem)
}
delete(p.inMemory, tenantID)
}
func (p *Pool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
for p.lru.Len() > 0 {
p.evictOldest()
}
}
type cacheEntry struct {
tenantID string
expiresAt time.Time
}
// Cache stores subdomain to tenant ID mappings.
type Cache struct {
mu sync.RWMutex
items map[string]cacheEntry
stop chan struct{}
}
func NewCache() *Cache {
c := &Cache{
items: make(map[string]cacheEntry),
stop: make(chan struct{}),
}
go c.cleanup()
return c
}
func (c *Cache) Get(subdomain string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.items[subdomain]
if !ok || time.Now().After(entry.expiresAt) {
return "", false
}
return entry.tenantID, true
}
func (c *Cache) Set(subdomain, tenantID string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[subdomain] = cacheEntry{
tenantID: tenantID,
expiresAt: time.Now().Add(cacheTTL),
}
}
func (c *Cache) Delete(subdomain string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, subdomain)
}
func (c *Cache) cleanup() {
ticker := time.NewTicker(cacheCleanupFreq)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.mu.Lock()
now := time.Now()
for k, v := range c.items {
if now.After(v.expiresAt) {
delete(c.items, k)
}
}
c.mu.Unlock()
case <-c.stop:
return
}
}
}
func (c *Cache) Close() {
close(c.stop)
}

481
internal/tenant/posts.go Normal file
View file

@ -0,0 +1,481 @@
package tenant
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
func (q *Queries) ListPosts(ctx context.Context, includeUnpublished bool) ([]Post, error) {
query := `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
FROM posts ORDER BY COALESCE(published_at, created_at) DESC`
if !includeUnpublished {
query = `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
FROM posts WHERE is_published = 1 ORDER BY COALESCE(published_at, created_at) DESC`
}
rows, err := q.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var posts []Post
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return nil, err
}
posts = append(posts, p)
}
return posts, rows.Err()
}
type ListPostsOptions struct {
Limit int
Offset int
Tag string
}
type ListPostsResult struct {
Posts []Post
Total int
}
func (q *Queries) ListPostsPaginated(ctx context.Context, opts ListPostsOptions) (*ListPostsResult, error) {
if opts.Limit <= 0 {
opts.Limit = 20
}
if opts.Limit > 100 {
opts.Limit = 100
}
var args []any
where := "WHERE is_published = 1"
if opts.Tag != "" {
where += " AND tags LIKE ?"
args = append(args, "%\""+opts.Tag+"\"%")
}
var total int
countQuery := "SELECT COUNT(*) FROM posts " + where
err := q.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
if err != nil {
return nil, err
}
query := `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
FROM posts ` + where + ` ORDER BY COALESCE(published_at, created_at) DESC LIMIT ? OFFSET ?`
args = append(args, opts.Limit, opts.Offset)
rows, err := q.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var posts []Post
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return nil, err
}
posts = append(posts, p)
}
return &ListPostsResult{Posts: posts, Total: total}, rows.Err()
}
func (q *Queries) GetPost(ctx context.Context, slug string) (*Post, error) {
row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
FROM posts WHERE slug = ?`, slug)
p, err := scanPost(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
func (q *Queries) GetPostByID(ctx context.Context, id string) (*Post, error) {
row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
FROM posts WHERE id = ?`, id)
p, err := scanPost(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
func (q *Queries) GetPostByAlias(ctx context.Context, alias string) (*Post, error) {
pattern := "%\"" + alias + "\"%"
row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
FROM posts WHERE aliases LIKE ? LIMIT 1`, pattern)
p, err := scanPost(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
func (q *Queries) CreatePost(ctx context.Context, p *Post) error {
if p.ID == "" {
p.ID = uuid.NewString()
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := q.db.ExecContext(ctx, `INSERT INTO posts (id, slug, title, description, tags, cover_image, content_md, content_html,
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage),
nullStr(p.ContentMD), nullStr(p.ContentHTML), boolToInt(p.IsPublished), boolToInt(p.MembersOnly),
timeToStr(p.PublishedAt), timeToStr(p.UpdatedAt), jsonStr(p.Aliases), now, now)
if err != nil {
return err
}
q.IndexPost(ctx, p)
return nil
}
func (q *Queries) UpdatePost(ctx context.Context, p *Post) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := q.db.ExecContext(ctx, `UPDATE posts SET
slug = ?, title = ?, description = ?, tags = ?, cover_image = ?, content_md = ?, content_html = ?,
is_published = ?, members_only = ?, published_at = ?, updated_at = ?, aliases = ?, modified_at = ?
WHERE id = ?`,
p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage),
nullStr(p.ContentMD), nullStr(p.ContentHTML), boolToInt(p.IsPublished), boolToInt(p.MembersOnly),
timeToStr(p.PublishedAt), timeToStr(p.UpdatedAt), jsonStr(p.Aliases), now, p.ID)
if err != nil {
return err
}
q.IndexPost(ctx, p)
return nil
}
func (q *Queries) DeletePost(ctx context.Context, id string) error {
post, _ := q.GetPostByID(ctx, id)
_, err := q.db.ExecContext(ctx, `DELETE FROM posts WHERE id = ?`, id)
if err == nil && post != nil {
q.RemoveFromIndex(ctx, post.Slug, "post")
}
return err
}
func (q *Queries) GetDraft(ctx context.Context, postID string) (*PostDraft, error) {
row := q.db.QueryRowContext(ctx, `SELECT post_id, slug, title, description, tags, cover_image, members_only, content_md, content_html, modified_at
FROM post_drafts WHERE post_id = ?`, postID)
d, err := scanDraft(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
func (q *Queries) SaveDraft(ctx context.Context, d *PostDraft) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := q.db.ExecContext(ctx, `INSERT INTO post_drafts (post_id, slug, title, description, tags, cover_image, members_only, content_md, content_html, modified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(post_id) DO UPDATE SET
slug = excluded.slug, title = excluded.title, description = excluded.description, tags = excluded.tags,
cover_image = excluded.cover_image, members_only = excluded.members_only, content_md = excluded.content_md,
content_html = excluded.content_html, modified_at = excluded.modified_at`,
d.PostID, d.Slug, nullStr(d.Title), nullStr(d.Description), jsonStr(d.Tags), nullStr(d.CoverImage),
boolToInt(d.MembersOnly), nullStr(d.ContentMD), nullStr(d.ContentHTML), now)
return err
}
func (q *Queries) DeleteDraft(ctx context.Context, postID string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM post_drafts WHERE post_id = ?`, postID)
return err
}
func (q *Queries) HasDraft(ctx context.Context, postID string) (bool, error) {
var count int64
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM post_drafts WHERE post_id = ?`, postID).Scan(&count)
return count > 0, err
}
func (q *Queries) CreateVersion(ctx context.Context, p *Post) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := q.db.ExecContext(ctx, `INSERT INTO post_versions (post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage),
nullStr(p.ContentMD), nullStr(p.ContentHTML), now)
return err
}
func (q *Queries) ListVersions(ctx context.Context, postID string) ([]PostVersion, error) {
rows, err := q.db.QueryContext(ctx, `SELECT id, post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at
FROM post_versions WHERE post_id = ? ORDER BY created_at DESC`, postID)
if err != nil {
return nil, err
}
defer rows.Close()
var versions []PostVersion
for rows.Next() {
v, err := scanVersion(rows)
if err != nil {
return nil, err
}
versions = append(versions, v)
}
return versions, rows.Err()
}
func (q *Queries) GetVersion(ctx context.Context, versionID int64) (*PostVersion, error) {
row := q.db.QueryRowContext(ctx, `SELECT id, post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at
FROM post_versions WHERE id = ?`, versionID)
v, err := scanVersion(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &v, nil
}
func (q *Queries) PruneVersions(ctx context.Context, postID string, keepCount int) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM post_versions AS pv
WHERE pv.post_id = ?1 AND pv.id NOT IN (
SELECT sub.id FROM post_versions AS sub WHERE sub.post_id = ?1 ORDER BY sub.created_at DESC LIMIT ?2
)`, postID, keepCount)
return err
}
func (q *Queries) Publish(ctx context.Context, postID string) error {
post, err := q.GetPostByID(ctx, postID)
if err != nil || post == nil {
return err
}
draft, err := q.GetDraft(ctx, postID)
if err != nil {
return err
}
now := time.Now().UTC()
if draft != nil {
if draft.Slug != post.Slug {
if !contains(post.Aliases, post.Slug) {
post.Aliases = append(post.Aliases, post.Slug)
}
}
post.Slug = draft.Slug
post.Title = draft.Title
post.Description = draft.Description
post.Tags = draft.Tags
post.CoverImage = draft.CoverImage
post.MembersOnly = draft.MembersOnly
post.ContentMD = draft.ContentMD
post.ContentHTML = draft.ContentHTML
}
if post.PublishedAt == nil {
post.PublishedAt = &now
}
post.UpdatedAt = &now
post.IsPublished = true
if err := q.UpdatePost(ctx, post); err != nil {
return err
}
if err := q.CreateVersion(ctx, post); err != nil {
return err
}
if err := q.DeleteDraft(ctx, postID); err != nil {
return err
}
return q.PruneVersions(ctx, postID, 10)
}
func (q *Queries) Unpublish(ctx context.Context, postID string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := q.db.ExecContext(ctx, `UPDATE posts SET is_published = 0, modified_at = ? WHERE id = ?`, now, postID)
return err
}
func (q *Queries) RestoreVersion(ctx context.Context, postID string, versionID int64) error {
version, err := q.GetVersion(ctx, versionID)
if err != nil || version == nil {
return err
}
return q.SaveDraft(ctx, &PostDraft{
PostID: postID,
Slug: version.Slug,
Title: version.Title,
Description: version.Description,
Tags: version.Tags,
CoverImage: version.CoverImage,
ContentMD: version.ContentMD,
ContentHTML: version.ContentHTML,
})
}
type scanner interface {
Scan(dest ...any) error
}
func scanPost(s scanner) (Post, error) {
var p Post
var title, desc, tags, cover, md, html, pubAt, updAt, aliases, createdAt, modAt sql.NullString
var isPub, memOnly sql.NullInt64
err := s.Scan(&p.ID, &p.Slug, &title, &desc, &tags, &cover, &md, &html,
&isPub, &memOnly, &pubAt, &updAt, &aliases, &createdAt, &modAt)
if err != nil {
return p, err
}
p.Title = title.String
p.Description = desc.String
p.Tags = parseJSON[[]string](tags.String)
p.CoverImage = cover.String
p.ContentMD = md.String
p.ContentHTML = html.String
p.IsPublished = isPub.Int64 == 1
p.MembersOnly = memOnly.Int64 == 1
p.PublishedAt = parseTimePtr(pubAt.String)
p.UpdatedAt = parseTimePtr(updAt.String)
p.Aliases = parseJSON[[]string](aliases.String)
p.CreatedAt = parseTime(createdAt.String)
p.ModifiedAt = parseTime(modAt.String)
return p, nil
}
func scanDraft(s scanner) (PostDraft, error) {
var d PostDraft
var title, desc, tags, cover, md, html, modAt sql.NullString
var memOnly sql.NullInt64
err := s.Scan(&d.PostID, &d.Slug, &title, &desc, &tags, &cover, &memOnly, &md, &html, &modAt)
if err != nil {
return d, err
}
d.Title = title.String
d.Description = desc.String
d.Tags = parseJSON[[]string](tags.String)
d.CoverImage = cover.String
d.MembersOnly = memOnly.Int64 == 1
d.ContentMD = md.String
d.ContentHTML = html.String
d.ModifiedAt = parseTime(modAt.String)
return d, nil
}
func scanVersion(s scanner) (PostVersion, error) {
var v PostVersion
var title, desc, tags, cover, md, html, createdAt sql.NullString
err := s.Scan(&v.ID, &v.PostID, &v.Slug, &title, &desc, &tags, &cover, &md, &html, &createdAt)
if err != nil {
return v, err
}
v.Title = title.String
v.Description = desc.String
v.Tags = parseJSON[[]string](tags.String)
v.CoverImage = cover.String
v.ContentMD = md.String
v.ContentHTML = html.String
v.CreatedAt = parseTime(createdAt.String)
return v, nil
}
func nullStr(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""}
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func jsonStr[T any](v T) sql.NullString {
b, _ := json.Marshal(v)
s := string(b)
if s == "null" || s == "[]" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
func parseJSON[T any](s string) T {
var v T
if s != "" {
json.Unmarshal([]byte(s), &v)
}
return v
}
func parseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339, "2006-01-02", "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t
}
}
return time.Time{}
}
func parseTimePtr(s string) *time.Time {
t := parseTime(s)
if t.IsZero() {
return nil
}
return &t
}
func timeToStr(t *time.Time) sql.NullString {
if t == nil {
return sql.NullString{}
}
return sql.NullString{String: t.UTC().Format(time.RFC3339), Valid: true}
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}

View file

@ -0,0 +1,24 @@
package tenant
import (
"context"
"database/sql"
)
type DB interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
type Queries struct {
db DB
}
func NewQueries(db *sql.DB) *Queries {
return &Queries{db: db}
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{db: tx}
}

View file

@ -0,0 +1,159 @@
package tenant
import (
"context"
"database/sql"
)
func (q *Queries) ListReactions(ctx context.Context, postSlug string) ([]Reaction, error) {
rows, err := q.db.QueryContext(ctx, `SELECT id, user_id, anon_id, post_slug, emoji, created_at
FROM reactions WHERE post_slug = ?`, postSlug)
if err != nil {
return nil, err
}
defer rows.Close()
var reactions []Reaction
for rows.Next() {
r, err := scanReaction(rows)
if err != nil {
return nil, err
}
reactions = append(reactions, r)
}
return reactions, rows.Err()
}
func (q *Queries) GetReactionCounts(ctx context.Context, postSlug string) (map[string]int, error) {
rows, err := q.db.QueryContext(ctx, `SELECT emoji, COUNT(*) as count FROM reactions WHERE post_slug = ? GROUP BY emoji`, postSlug)
if err != nil {
return nil, err
}
defer rows.Close()
counts := make(map[string]int)
for rows.Next() {
var emoji string
var count int64
if err := rows.Scan(&emoji, &count); err != nil {
return nil, err
}
counts[emoji] = int(count)
}
return counts, rows.Err()
}
func (q *Queries) ToggleReaction(ctx context.Context, userID, anonID, postSlug, emoji string) (bool, error) {
var exists bool
if userID != "" {
var dummy int64
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM reactions WHERE user_id = ? AND post_slug = ? AND emoji = ?`,
userID, postSlug, emoji).Scan(&dummy)
exists = err == nil
} else if anonID != "" {
var dummy int64
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM reactions WHERE anon_id = ? AND post_slug = ? AND emoji = ?`,
anonID, postSlug, emoji).Scan(&dummy)
exists = err == nil
} else {
return false, nil
}
if !exists {
if userID != "" {
_, err := q.db.ExecContext(ctx, `INSERT INTO reactions (user_id, post_slug, emoji) VALUES (?, ?, ?)`,
userID, postSlug, emoji)
return true, err
}
_, err := q.db.ExecContext(ctx, `INSERT INTO reactions (anon_id, post_slug, emoji) VALUES (?, ?, ?)`,
anonID, postSlug, emoji)
return true, err
}
if userID != "" {
_, err := q.db.ExecContext(ctx, `DELETE FROM reactions WHERE user_id = ? AND post_slug = ? AND emoji = ?`,
userID, postSlug, emoji)
return false, err
}
_, err := q.db.ExecContext(ctx, `DELETE FROM reactions WHERE anon_id = ? AND post_slug = ? AND emoji = ?`,
anonID, postSlug, emoji)
return false, err
}
func (q *Queries) GetUserReactions(ctx context.Context, userID, postSlug string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, `SELECT emoji FROM reactions WHERE user_id = ? AND post_slug = ?`, userID, postSlug)
if err != nil {
return nil, err
}
defer rows.Close()
var emojis []string
for rows.Next() {
var emoji string
if err := rows.Scan(&emoji); err != nil {
return nil, err
}
emojis = append(emojis, emoji)
}
return emojis, rows.Err()
}
func (q *Queries) GetAnonReactions(ctx context.Context, anonID, postSlug string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, `SELECT emoji FROM reactions WHERE anon_id = ? AND post_slug = ?`, anonID, postSlug)
if err != nil {
return nil, err
}
defer rows.Close()
var emojis []string
for rows.Next() {
var emoji string
if err := rows.Scan(&emoji); err != nil {
return nil, err
}
emojis = append(emojis, emoji)
}
return emojis, rows.Err()
}
func (q *Queries) HasUserReacted(ctx context.Context, userID, postSlug string) (bool, error) {
var dummy int64
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM reactions WHERE user_id = ? AND post_slug = ? LIMIT 1`,
userID, postSlug).Scan(&dummy)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, nil
}
return true, nil
}
func (q *Queries) HasAnonReacted(ctx context.Context, anonID, postSlug string) (bool, error) {
var dummy int64
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM reactions WHERE anon_id = ? AND post_slug = ? LIMIT 1`,
anonID, postSlug).Scan(&dummy)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, nil
}
return true, nil
}
func scanReaction(s scanner) (Reaction, error) {
var r Reaction
var userID, anonID, createdAt sql.NullString
err := s.Scan(&r.ID, &userID, &anonID, &r.PostSlug, &r.Emoji, &createdAt)
if err != nil {
return r, err
}
r.UserID = userID.String
r.AnonID = anonID.String
r.CreatedAt = parseTime(createdAt.String)
return r, nil
}

621
internal/tenant/runner.go Normal file
View file

@ -0,0 +1,621 @@
package tenant
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
extism "github.com/extism/go-sdk"
)
type PluginRunner struct {
db *sql.DB
q *Queries
tenantID string
cache map[string]*extism.Plugin
mu sync.RWMutex
}
func NewPluginRunner(db *sql.DB, tenantID string) *PluginRunner {
return &PluginRunner{
db: db,
q: NewQueries(db),
tenantID: tenantID,
cache: make(map[string]*extism.Plugin),
}
}
type HookEvent struct {
Hook string `json:"hook"`
Data map[string]any `json:"data"`
}
type PluginResult struct {
PluginID string `json:"plugin_id"`
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
// TriggerHook executes plugins for an event hook (fire-and-forget)
func (r *PluginRunner) TriggerHook(ctx context.Context, hook string, data map[string]any) []PluginResult {
plugins, err := r.q.GetPluginsByHook(ctx, hook)
if err != nil || len(plugins) == 0 {
return nil
}
secrets, _ := GetSecretsMap(r.db, r.tenantID)
var results []PluginResult
for _, p := range plugins {
result := r.runPlugin(ctx, &p, hook, data, secrets)
results = append(results, result)
}
return results
}
// ValidationResult represents the result of a validation hook
type ValidationResult struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
}
// TriggerValidation executes a validation hook and returns whether the action is allowed
// Returns (allowed, reason, error). If no plugins exist, allowed defaults to true.
func (r *PluginRunner) TriggerValidation(ctx context.Context, hook string, data map[string]any) (bool, string, error) {
plugins, err := r.q.GetPluginsByHook(ctx, hook)
if err != nil {
return true, "", err // Default to allowed on error
}
if len(plugins) == 0 {
return true, "", nil // Default to allowed if no plugins
}
secrets, _ := GetSecretsMap(r.db, r.tenantID)
// Run first enabled plugin only (validation is exclusive)
for _, p := range plugins {
result := r.runPlugin(ctx, &p, hook, data, secrets)
if !result.Success {
// Plugin failed to run, default to allowed
continue
}
var validation ValidationResult
if err := json.Unmarshal([]byte(result.Output), &validation); err != nil {
continue // Invalid output, skip this plugin
}
if !validation.Allowed {
return false, validation.Reason, nil
}
}
return true, "", nil
}
// TriggerTransform executes a transform hook and returns the transformed data
// If no plugins exist or all fail, returns the original data unchanged.
func (r *PluginRunner) TriggerTransform(ctx context.Context, hook string, data map[string]any) (map[string]any, error) {
plugins, err := r.q.GetPluginsByHook(ctx, hook)
if err != nil || len(plugins) == 0 {
return data, nil
}
secrets, _ := GetSecretsMap(r.db, r.tenantID)
current := data
// Chain transforms - each plugin receives output of previous
for _, p := range plugins {
result := r.runPlugin(ctx, &p, hook, current, secrets)
if !result.Success {
continue // Skip failed plugins
}
var transformed map[string]any
if err := json.Unmarshal([]byte(result.Output), &transformed); err != nil {
continue // Invalid output, skip
}
current = transformed
}
return current, nil
}
func (r *PluginRunner) runPlugin(ctx context.Context, p *Plugin, hook string, data map[string]any, secrets map[string]string) PluginResult {
start := time.Now()
result := PluginResult{PluginID: p.ID}
plugin, err := r.getOrCreatePlugin(p, secrets)
if err != nil {
result.Error = err.Error()
result.Duration = time.Since(start).Milliseconds()
return result
}
input, _ := json.Marshal(data)
funcName := hookToFunction(hook)
_, output, err := plugin.Call(funcName, input)
if err != nil {
result.Error = err.Error()
} else {
result.Success = true
result.Output = string(output)
}
result.Duration = time.Since(start).Milliseconds()
return result
}
func (r *PluginRunner) getOrCreatePlugin(p *Plugin, secrets map[string]string) (*extism.Plugin, error) {
r.mu.RLock()
cached, ok := r.cache[p.ID]
r.mu.RUnlock()
if ok {
return cached, nil
}
r.mu.Lock()
defer r.mu.Unlock()
if cached, ok = r.cache[p.ID]; ok {
return cached, nil
}
manifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{Data: p.Wasm},
},
AllowedHosts: []string{"*"},
Config: secrets,
}
config := extism.PluginConfig{
EnableWasi: true,
}
plugin, err := extism.NewPlugin(context.Background(), manifest, config, r.hostFunctions())
if err != nil {
return nil, fmt.Errorf("create plugin: %w", err)
}
r.cache[p.ID] = plugin
return plugin, nil
}
func (r *PluginRunner) hostFunctions() []extism.HostFunction {
return []extism.HostFunction{
r.httpRequestHost(),
r.kvGetHost(),
r.kvSetHost(),
r.logHost(),
}
}
func (r *PluginRunner) httpRequestHost() extism.HostFunction {
return extism.NewHostFunctionWithStack(
"http_request",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
input, err := p.ReadBytes(stack[0])
if err != nil {
return
}
var req struct {
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
if err := json.Unmarshal(input, &req); err != nil {
return
}
if req.Method == "" {
req.Method = "GET"
}
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bytes.NewBufferString(req.Body))
if err != nil {
return
}
for k, v := range req.Headers {
httpReq.Header.Set(k, v)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
errResp, _ := json.Marshal(map[string]any{"error": err.Error()})
offset, _ := p.WriteBytes(errResp)
stack[0] = offset
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
headers := make(map[string]string)
for k := range resp.Header {
headers[k] = resp.Header.Get(k)
}
result, _ := json.Marshal(map[string]any{
"status": resp.StatusCode,
"headers": headers,
"body": string(body),
})
offset, _ := p.WriteBytes(result)
stack[0] = offset
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
)
}
func (r *PluginRunner) kvGetHost() extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kv_get",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
key, err := p.ReadString(stack[0])
if err != nil {
return
}
value, _ := GetSecret(r.db, r.tenantID, "kv:"+key)
offset, _ := p.WriteString(value)
stack[0] = offset
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
)
}
func (r *PluginRunner) kvSetHost() extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kv_set",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
input, err := p.ReadBytes(stack[0])
if err != nil {
return
}
var kv struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.Unmarshal(input, &kv); err != nil {
return
}
SetSecret(r.db, r.tenantID, "kv:"+kv.Key, kv.Value)
stack[0] = 0
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
)
}
func (r *PluginRunner) logHost() extism.HostFunction {
return extism.NewHostFunctionWithStack(
"log",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
msg, _ := p.ReadString(stack[0])
fmt.Printf("[plugin] %s\n", msg)
stack[0] = 0
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
)
}
func (r *PluginRunner) InvalidateCache(pluginID string) {
r.mu.Lock()
defer r.mu.Unlock()
if plugin, ok := r.cache[pluginID]; ok {
plugin.Close(context.Background())
delete(r.cache, pluginID)
}
}
func (r *PluginRunner) Close() {
r.mu.Lock()
defer r.mu.Unlock()
for _, plugin := range r.cache {
plugin.Close(context.Background())
}
r.cache = make(map[string]*extism.Plugin)
}
// HookPattern defines how a hook should be executed
type HookPattern string
const (
PatternEvent HookPattern = "event" // Fire-and-forget notifications
PatternValidation HookPattern = "validation" // Returns allowed/rejected decision
PatternTransform HookPattern = "transform" // Modifies and returns data
)
// HookInfo contains metadata about a hook
type HookInfo struct {
Name string
Pattern HookPattern
Description string
}
// AvailableHooks lists all supported hooks with metadata
var AvailableHooks = []HookInfo{
// Content hooks
{Name: "post.published", Pattern: PatternEvent, Description: "Triggered when a post is published"},
{Name: "post.updated", Pattern: PatternEvent, Description: "Triggered when a post is updated"},
{Name: "content.render", Pattern: PatternTransform, Description: "Transform HTML before display"},
// Engagement hooks
{Name: "comment.validate", Pattern: PatternValidation, Description: "Validate comment before creation"},
{Name: "comment.created", Pattern: PatternEvent, Description: "Triggered when a comment is created"},
{Name: "member.subscribed", Pattern: PatternEvent, Description: "Triggered when a member subscribes"},
// Utility hooks
{Name: "asset.uploaded", Pattern: PatternEvent, Description: "Triggered when an asset is uploaded"},
{Name: "analytics.sync", Pattern: PatternEvent, Description: "Triggered during analytics sync"},
}
// GetHookPattern returns the pattern for a given hook
func GetHookPattern(hook string) HookPattern {
for _, h := range AvailableHooks {
if h.Name == hook {
return h.Pattern
}
}
return PatternEvent
}
// GetHookNames returns just the hook names (for API responses)
func GetHookNames() []string {
names := make([]string, len(AvailableHooks))
for i, h := range AvailableHooks {
names[i] = h.Name
}
return names
}
// TestPluginRunner runs plugins for testing with log capture
type TestPluginRunner struct {
db *sql.DB
tenantID string
secrets map[string]string
logs []string
}
// TestResult contains the result of a plugin test run
type TestResult struct {
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Logs []string `json:"logs"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
func NewTestPluginRunner(db *sql.DB, tenantID string, secrets map[string]string) *TestPluginRunner {
return &TestPluginRunner{
db: db,
tenantID: tenantID,
secrets: secrets,
logs: []string{},
}
}
func (r *TestPluginRunner) RunTest(ctx context.Context, wasm []byte, hook string, data map[string]any) TestResult {
start := time.Now()
result := TestResult{Logs: []string{}}
manifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{Data: wasm},
},
AllowedHosts: []string{"*"},
Config: r.secrets,
}
config := extism.PluginConfig{
EnableWasi: true,
}
plugin, err := extism.NewPlugin(ctx, manifest, config, r.testHostFunctions())
if err != nil {
result.Error = fmt.Sprintf("Failed to create plugin: %v", err)
result.Duration = time.Since(start).Milliseconds()
return result
}
defer plugin.Close(ctx)
input, _ := json.Marshal(data)
funcName := hookToFunction(hook)
_, output, err := plugin.Call(funcName, input)
if err != nil {
result.Error = err.Error()
} else {
result.Success = true
result.Output = string(output)
}
result.Logs = r.logs
result.Duration = time.Since(start).Milliseconds()
return result
}
func (r *TestPluginRunner) testHostFunctions() []extism.HostFunction {
return []extism.HostFunction{
r.testHttpRequestHost(),
r.testKvGetHost(),
r.testKvSetHost(),
r.testLogHost(),
}
}
func (r *TestPluginRunner) testHttpRequestHost() extism.HostFunction {
return extism.NewHostFunctionWithStack(
"http_request",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
input, err := p.ReadBytes(stack[0])
if err != nil {
return
}
var req struct {
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
if err := json.Unmarshal(input, &req); err != nil {
return
}
if req.Method == "" {
req.Method = "GET"
}
r.logs = append(r.logs, fmt.Sprintf("[HTTP] %s %s", req.Method, req.URL))
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bytes.NewBufferString(req.Body))
if err != nil {
errResp, _ := json.Marshal(map[string]any{"error": err.Error()})
offset, _ := p.WriteBytes(errResp)
stack[0] = offset
return
}
for k, v := range req.Headers {
httpReq.Header.Set(k, v)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
r.logs = append(r.logs, fmt.Sprintf("[HTTP] Error: %v", err))
errResp, _ := json.Marshal(map[string]any{"error": err.Error()})
offset, _ := p.WriteBytes(errResp)
stack[0] = offset
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
r.logs = append(r.logs, fmt.Sprintf("[HTTP] Response: %d (%d bytes)", resp.StatusCode, len(body)))
headers := make(map[string]string)
for k := range resp.Header {
headers[k] = resp.Header.Get(k)
}
result, _ := json.Marshal(map[string]any{
"status": resp.StatusCode,
"headers": headers,
"body": string(body),
})
offset, _ := p.WriteBytes(result)
stack[0] = offset
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
)
}
func (r *TestPluginRunner) testKvGetHost() extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kv_get",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
key, err := p.ReadString(stack[0])
if err != nil {
return
}
value, _ := GetSecret(r.db, r.tenantID, "kv:"+key)
r.logs = append(r.logs, fmt.Sprintf("[KV] GET %s", key))
offset, _ := p.WriteString(value)
stack[0] = offset
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
)
}
func (r *TestPluginRunner) testKvSetHost() extism.HostFunction {
return extism.NewHostFunctionWithStack(
"kv_set",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
input, err := p.ReadBytes(stack[0])
if err != nil {
return
}
var kv struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.Unmarshal(input, &kv); err != nil {
return
}
r.logs = append(r.logs, fmt.Sprintf("[KV] SET %s = %s", kv.Key, kv.Value))
SetSecret(r.db, r.tenantID, "kv:"+kv.Key, kv.Value)
stack[0] = 0
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
)
}
func (r *TestPluginRunner) testLogHost() extism.HostFunction {
return extism.NewHostFunctionWithStack(
"log",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
msg, _ := p.ReadString(stack[0])
r.logs = append(r.logs, fmt.Sprintf("[LOG] %s", msg))
stack[0] = 0
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
)
}
func hookToFunction(hook string) string {
switch hook {
case "post.published":
return "on_post_published"
case "post.updated":
return "on_post_updated"
case "content.render":
return "render_content"
case "comment.validate":
return "validate_comment"
case "comment.created":
return "on_comment_created"
case "member.subscribed":
return "on_member_subscribed"
case "asset.uploaded":
return "on_asset_uploaded"
case "analytics.sync":
return "on_analytics_sync"
default:
return "run"
}
}

67
internal/tenant/search.go Normal file
View file

@ -0,0 +1,67 @@
package tenant
import (
"context"
)
type SearchResult struct {
Slug string
Collection string
Title string
Snippet string
Type string
URL string
Date string
}
func (q *Queries) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
if limit <= 0 {
limit = 20
}
rows, err := q.db.QueryContext(ctx,
`SELECT slug, collection_slug, title, snippet(search_index, 4, '<mark>', '</mark>', '...', 32), type, url, date
FROM search_index
WHERE search_index MATCH ?
ORDER BY rank
LIMIT ?`, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var r SearchResult
if err := rows.Scan(&r.Slug, &r.Collection, &r.Title, &r.Snippet, &r.Type, &r.URL, &r.Date); err != nil {
return nil, err
}
results = append(results, r)
}
return results, rows.Err()
}
func (q *Queries) IndexPost(ctx context.Context, p *Post) error {
q.db.ExecContext(ctx, `DELETE FROM search_index WHERE slug = ? AND type = 'post'`, p.Slug)
if !p.IsPublished {
return nil
}
dateStr := ""
if p.PublishedAt != nil {
dateStr = p.PublishedAt.Format("2006-01-02")
}
_, err := q.db.ExecContext(ctx,
`INSERT INTO search_index (slug, collection_slug, title, description, content, type, url, date)
VALUES (?, ?, ?, ?, ?, 'post', ?, ?)`,
p.Slug, "", p.Title, p.Description, p.ContentMD,
"/posts/"+p.Slug, dateStr)
return err
}
func (q *Queries) RemoveFromIndex(ctx context.Context, slug, itemType string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM search_index WHERE slug = ? AND type = ?`, slug, itemType)
return err
}

202
internal/tenant/secrets.go Normal file
View file

@ -0,0 +1,202 @@
package tenant
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"time"
)
type Secret struct {
Key string
CreatedAt time.Time
UpdatedAt time.Time
}
var masterKey []byte
func init() {
key := os.Getenv("SECRETS_MASTER_KEY")
if key == "" {
key = "writekit-dev-key-change-in-prod"
}
hash := sha256.Sum256([]byte(key))
masterKey = hash[:]
}
func deriveKey(tenantID string) []byte {
combined := append(masterKey, []byte(tenantID)...)
hash := sha256.Sum256(combined)
return hash[:]
}
func encrypt(plaintext []byte, tenantID string) (ciphertext, nonce []byte, err error) {
key := deriveKey(tenantID)
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, err
}
nonce = make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, nil, err
}
ciphertext = gcm.Seal(nil, nonce, plaintext, nil)
return ciphertext, nonce, nil
}
func decrypt(ciphertext, nonce []byte, tenantID string) ([]byte, error) {
key := deriveKey(tenantID)
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return gcm.Open(nil, nonce, ciphertext, nil)
}
func ensureSecretsTable(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS secrets (
key TEXT PRIMARY KEY,
value BLOB NOT NULL,
nonce BLOB NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`)
return err
}
func SetSecret(db *sql.DB, tenantID, key, value string) error {
if err := ensureSecretsTable(db); err != nil {
return err
}
ciphertext, nonce, err := encrypt([]byte(value), tenantID)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
_, err = db.Exec(`
INSERT INTO secrets (key, value, nonce, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
nonce = excluded.nonce,
updated_at = CURRENT_TIMESTAMP
`, key, ciphertext, nonce)
return err
}
func GetSecret(db *sql.DB, tenantID, key string) (string, error) {
if err := ensureSecretsTable(db); err != nil {
return "", err
}
var ciphertext, nonce []byte
err := db.QueryRow(`SELECT value, nonce FROM secrets WHERE key = ?`, key).Scan(&ciphertext, &nonce)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
plaintext, err := decrypt(ciphertext, nonce, tenantID)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
return string(plaintext), nil
}
func DeleteSecret(db *sql.DB, key string) error {
if err := ensureSecretsTable(db); err != nil {
return err
}
_, err := db.Exec(`DELETE FROM secrets WHERE key = ?`, key)
return err
}
func ListSecrets(db *sql.DB) ([]Secret, error) {
if err := ensureSecretsTable(db); err != nil {
return nil, err
}
rows, err := db.Query(`SELECT key, created_at, updated_at FROM secrets ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
var secrets []Secret
for rows.Next() {
var s Secret
var createdAt, updatedAt string
if err := rows.Scan(&s.Key, &createdAt, &updatedAt); err != nil {
return nil, err
}
s.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
s.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
secrets = append(secrets, s)
}
return secrets, rows.Err()
}
func GetSecretsMap(db *sql.DB, tenantID string) (map[string]string, error) {
if err := ensureSecretsTable(db); err != nil {
return nil, err
}
rows, err := db.Query(`SELECT key, value, nonce FROM secrets`)
if err != nil {
return nil, err
}
defer rows.Close()
secrets := make(map[string]string)
for rows.Next() {
var key string
var ciphertext, nonce []byte
if err := rows.Scan(&key, &ciphertext, &nonce); err != nil {
return nil, err
}
plaintext, err := decrypt(ciphertext, nonce, tenantID)
if err != nil {
continue
}
secrets[key] = string(plaintext)
}
return secrets, rows.Err()
}
func MaskSecret(value string) string {
if len(value) <= 8 {
return "••••••••"
}
return value[:4] + "••••" + value[len(value)-4:]
}
func GenerateSecretID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}

View file

@ -0,0 +1,84 @@
package tenant
import (
"context"
)
func (q *Queries) GetSettings(ctx context.Context) (Settings, error) {
rows, err := q.db.QueryContext(ctx, `SELECT key, value FROM site_settings`)
if err != nil {
return nil, err
}
defer rows.Close()
settings := make(Settings)
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return nil, err
}
settings[key] = value
}
return settings, rows.Err()
}
func (q *Queries) GetSetting(ctx context.Context, key string) (string, error) {
var value string
err := q.db.QueryRowContext(ctx, `SELECT value FROM site_settings WHERE key = ?`, key).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
func (q *Queries) SetSetting(ctx context.Context, key, value string) error {
_, err := q.db.ExecContext(ctx, `INSERT INTO site_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`, key, value)
return err
}
func (q *Queries) SetSettings(ctx context.Context, settings Settings) error {
for key, value := range settings {
if err := q.SetSetting(ctx, key, value); err != nil {
return err
}
}
return nil
}
func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM site_settings WHERE key = ?`, key)
return err
}
var defaultSettings = map[string]string{
"comments_enabled": "true",
"reactions_enabled": "true",
"reaction_mode": "upvote",
"reaction_emojis": "👍,❤️,😂,😮,😢",
"upvote_icon": "👍",
"reactions_require_auth": "false",
}
func (q *Queries) GetSettingWithDefault(ctx context.Context, key string) string {
value, err := q.GetSetting(ctx, key)
if err != nil || value == "" {
if def, ok := defaultSettings[key]; ok {
return def
}
return ""
}
return value
}
func (q *Queries) GetInteractionConfig(ctx context.Context) map[string]any {
return map[string]any{
"comments_enabled": q.GetSettingWithDefault(ctx, "comments_enabled") == "true",
"reactions_enabled": q.GetSettingWithDefault(ctx, "reactions_enabled") == "true",
"reaction_mode": q.GetSettingWithDefault(ctx, "reaction_mode"),
"reaction_emojis": q.GetSettingWithDefault(ctx, "reaction_emojis"),
"upvote_icon": q.GetSettingWithDefault(ctx, "upvote_icon"),
"reactions_require_auth": q.GetSettingWithDefault(ctx, "reactions_require_auth") == "true",
}
}

273
internal/tenant/sqlite.go Normal file
View file

@ -0,0 +1,273 @@
package tenant
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
func openDB(dataDir, tenantID string, inMemory bool) (*sql.DB, error) {
var dsn string
if inMemory {
// named in-memory DB with shared cache so all 'connections' share the same database
dsn = "file:" + tenantID + "?mode=memory&cache=shared&_pragma=busy_timeout(5000)"
} else {
dbPath := filepath.Join(dataDir, tenantID+".db")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, err
}
dsn = dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
if err := initSchema(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func initSchema(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT,
description TEXT,
tags TEXT,
cover_image TEXT,
content_md TEXT,
content_html TEXT,
is_published INTEGER DEFAULT 0,
members_only INTEGER DEFAULT 0,
published_at TEXT,
updated_at TEXT,
aliases TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
modified_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published_at DESC) WHERE is_published = 1;
CREATE TABLE IF NOT EXISTS post_drafts (
post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
title TEXT,
description TEXT,
tags TEXT,
cover_image TEXT,
members_only INTEGER DEFAULT 0,
content_md TEXT,
content_html TEXT,
modified_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS post_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
title TEXT,
description TEXT,
tags TEXT,
cover_image TEXT,
content_md TEXT,
content_html TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_post_versions_post ON post_versions(post_id);
CREATE TABLE IF NOT EXISTS pages (
path TEXT PRIMARY KEY,
html BLOB,
etag TEXT,
built_at TEXT
);
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
r2_key TEXT NOT NULL,
content_type TEXT,
size INTEGER,
width INTEGER,
height INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
name TEXT,
avatar_url TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id),
post_slug TEXT NOT NULL,
content TEXT NOT NULL,
content_html TEXT,
parent_id INTEGER REFERENCES comments(id),
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_slug);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT REFERENCES users(id),
anon_id TEXT,
post_slug TEXT NOT NULL,
emoji TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, post_slug, emoji),
UNIQUE(anon_id, post_slug, emoji)
);
CREATE INDEX IF NOT EXISTS idx_reactions_post ON reactions(post_slug);
CREATE TABLE IF NOT EXISTS page_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
post_slug TEXT,
referrer TEXT,
user_agent TEXT,
visitor_hash TEXT,
utm_source TEXT,
utm_medium TEXT,
utm_campaign TEXT,
device_type TEXT,
browser TEXT,
os TEXT,
country TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_page_views_path ON page_views(path);
CREATE INDEX IF NOT EXISTS idx_page_views_created ON page_views(created_at);
CREATE INDEX IF NOT EXISTS idx_page_views_visitor ON page_views(visitor_hash);
CREATE TABLE IF NOT EXISTS daily_analytics (
date TEXT PRIMARY KEY,
requests INTEGER DEFAULT 0,
page_views INTEGER DEFAULT 0,
unique_visitors INTEGER DEFAULT 0,
bandwidth INTEGER DEFAULT 0,
browsers TEXT,
os TEXT,
devices TEXT,
countries TEXT,
paths TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS members (
user_id TEXT PRIMARY KEY REFERENCES users(id),
email TEXT NOT NULL,
name TEXT,
tier TEXT NOT NULL,
status TEXT NOT NULL,
expires_at TEXT,
synced_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS api_keys (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT
);
CREATE TABLE IF NOT EXISTS components (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
source TEXT NOT NULL,
compiled TEXT,
client_directive TEXT DEFAULT 'load',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
language TEXT NOT NULL,
source TEXT NOT NULL,
wasm BLOB,
hooks TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS webhooks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
events TEXT NOT NULL,
secret TEXT,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_triggered_at TEXT,
last_status TEXT
);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
event TEXT NOT NULL,
payload TEXT,
status TEXT NOT NULL,
response_code INTEGER,
response_body TEXT,
attempts INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, created_at DESC);
`
_, err := db.Exec(schema)
if err != nil {
return fmt.Errorf("init schema: %w", err)
}
_, err = db.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
slug UNINDEXED,
collection_slug UNINDEXED,
title,
description,
content,
type UNINDEXED,
url UNINDEXED,
date UNINDEXED
)
`)
if err != nil {
return fmt.Errorf("init fts5: %w", err)
}
return nil
}

31
internal/tenant/sync.go Normal file
View file

@ -0,0 +1,31 @@
package tenant
import (
"context"
"time"
)
type MemberSyncer struct {
pool *Pool
}
func NewMemberSyncer(pool *Pool) *MemberSyncer {
return &MemberSyncer{pool: pool}
}
func (s *MemberSyncer) SyncMember(ctx context.Context, tenantID, userID, email, name, tier, status string, expiresAt *time.Time) error {
db, err := s.pool.Get(tenantID)
if err != nil {
return err
}
q := NewQueries(db)
return q.UpsertMember(ctx, &Member{
UserID: userID,
Email: email,
Name: name,
Tier: tier,
Status: status,
ExpiresAt: expiresAt,
})
}

117
internal/tenant/users.go Normal file
View file

@ -0,0 +1,117 @@
package tenant
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"time"
"github.com/google/uuid"
)
func (q *Queries) GetUserByID(ctx context.Context, id string) (*User, error) {
row := q.db.QueryRowContext(ctx, `SELECT id, email, name, avatar_url, created_at FROM users WHERE id = ?`, id)
u, err := scanUser(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (*User, error) {
row := q.db.QueryRowContext(ctx, `SELECT id, email, name, avatar_url, created_at FROM users WHERE email = ?`, email)
u, err := scanUser(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
func (q *Queries) CreateUser(ctx context.Context, u *User) error {
if u.ID == "" {
u.ID = uuid.NewString()
}
_, err := q.db.ExecContext(ctx, `INSERT INTO users (id, email, name, avatar_url) VALUES (?, ?, ?, ?)`,
u.ID, u.Email, nullStr(u.Name), nullStr(u.AvatarURL))
return err
}
func (q *Queries) ValidateSession(ctx context.Context, token string) (*Session, error) {
var s Session
var expiresAt string
err := q.db.QueryRowContext(ctx, `SELECT token, user_id, expires_at FROM sessions WHERE token = ?`, token).
Scan(&s.Token, &s.UserID, &expiresAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
s.ExpiresAt, _ = time.Parse(time.RFC3339, expiresAt)
if time.Now().After(s.ExpiresAt) {
q.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, token)
return nil, nil
}
return &s, nil
}
func (q *Queries) CreateSession(ctx context.Context, userID string) (*Session, error) {
token, err := generateToken()
if err != nil {
return nil, err
}
expires := time.Now().Add(30 * 24 * time.Hour)
expiresStr := expires.UTC().Format(time.RFC3339)
_, err = q.db.ExecContext(ctx, `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`,
token, userID, expiresStr)
if err != nil {
return nil, err
}
return &Session{
Token: token,
UserID: userID,
ExpiresAt: expires,
}, nil
}
func (q *Queries) DeleteSession(ctx context.Context, token string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, token)
return err
}
func scanUser(s scanner) (User, error) {
var u User
var name, avatarURL, createdAt sql.NullString
err := s.Scan(&u.ID, &u.Email, &name, &avatarURL, &createdAt)
if err != nil {
return u, err
}
u.Name = name.String
u.AvatarURL = avatarURL.String
u.CreatedAt = parseTime(createdAt.String)
return u, nil
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

308
internal/tenant/webhooks.go Normal file
View file

@ -0,0 +1,308 @@
package tenant
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
)
type Webhook struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Events []string `json:"events"`
Secret string `json:"secret,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
LastStatus *string `json:"last_status"`
}
type WebhookDelivery struct {
ID int64 `json:"id"`
WebhookID string `json:"webhook_id"`
Event string `json:"event"`
Payload string `json:"payload"`
Status string `json:"status"`
ResponseCode *int `json:"response_code"`
ResponseBody *string `json:"response_body"`
Attempts int `json:"attempts"`
CreatedAt time.Time `json:"created_at"`
}
type WebhookPayload struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data any `json:"data"`
}
func (q *Queries) CountWebhooks(ctx context.Context) (int, error) {
var count int
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM webhooks`).Scan(&count)
return count, err
}
func (q *Queries) CreateWebhook(ctx context.Context, name, url string, events []string, secret string) (*Webhook, error) {
id := uuid.New().String()
eventsJSON, _ := json.Marshal(events)
_, err := q.db.ExecContext(ctx, `
INSERT INTO webhooks (id, name, url, events, secret, enabled, created_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
`, id, name, url, string(eventsJSON), secret)
if err != nil {
return nil, err
}
return q.GetWebhook(ctx, id)
}
func (q *Queries) GetWebhook(ctx context.Context, id string) (*Webhook, error) {
var w Webhook
var eventsJSON string
var lastTriggeredAt, lastStatus sql.NullString
var createdAtStr string
err := q.db.QueryRowContext(ctx, `
SELECT id, name, url, events, secret, enabled, created_at, last_triggered_at, last_status
FROM webhooks WHERE id = ?
`, id).Scan(&w.ID, &w.Name, &w.URL, &eventsJSON, &w.Secret, &w.Enabled, &createdAtStr, &lastTriggeredAt, &lastStatus)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal([]byte(eventsJSON), &w.Events)
w.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
if lastTriggeredAt.Valid {
t, _ := time.Parse(time.RFC3339, lastTriggeredAt.String)
w.LastTriggeredAt = &t
}
if lastStatus.Valid {
w.LastStatus = &lastStatus.String
}
return &w, nil
}
func (q *Queries) ListWebhooks(ctx context.Context) ([]Webhook, error) {
rows, err := q.db.QueryContext(ctx, `
SELECT id, name, url, events, secret, enabled, created_at, last_triggered_at, last_status
FROM webhooks ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var webhooks []Webhook
for rows.Next() {
var w Webhook
var eventsJSON string
var lastTriggeredAt, lastStatus sql.NullString
var createdAtStr string
if err := rows.Scan(&w.ID, &w.Name, &w.URL, &eventsJSON, &w.Secret, &w.Enabled, &createdAtStr, &lastTriggeredAt, &lastStatus); err != nil {
return nil, err
}
json.Unmarshal([]byte(eventsJSON), &w.Events)
w.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
if lastTriggeredAt.Valid {
t, _ := time.Parse(time.RFC3339, lastTriggeredAt.String)
w.LastTriggeredAt = &t
}
if lastStatus.Valid {
w.LastStatus = &lastStatus.String
}
webhooks = append(webhooks, w)
}
return webhooks, rows.Err()
}
func (q *Queries) UpdateWebhook(ctx context.Context, id, name, url string, events []string, secret string, enabled bool) error {
eventsJSON, _ := json.Marshal(events)
_, err := q.db.ExecContext(ctx, `
UPDATE webhooks SET name = ?, url = ?, events = ?, secret = ?, enabled = ?
WHERE id = ?
`, name, url, string(eventsJSON), secret, enabled, id)
return err
}
func (q *Queries) DeleteWebhook(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM webhooks WHERE id = ?`, id)
return err
}
func (q *Queries) ListWebhooksByEvent(ctx context.Context, event string) ([]Webhook, error) {
webhooks, err := q.ListWebhooks(ctx)
if err != nil {
return nil, err
}
var result []Webhook
for _, w := range webhooks {
if !w.Enabled {
continue
}
for _, e := range w.Events {
if e == event {
result = append(result, w)
break
}
}
}
return result, nil
}
func (q *Queries) TriggerWebhooks(ctx context.Context, event string, data any, baseURL string) {
webhooks, err := q.ListWebhooksByEvent(ctx, event)
if err != nil || len(webhooks) == 0 {
return
}
payload := WebhookPayload{
Event: event,
Timestamp: time.Now().UTC(),
Data: data,
}
payloadJSON, _ := json.Marshal(payload)
for _, w := range webhooks {
go q.deliverWebhook(ctx, w, event, payloadJSON)
}
}
func (q *Queries) deliverWebhook(ctx context.Context, w Webhook, event string, payloadJSON []byte) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", w.URL, bytes.NewReader(payloadJSON))
if err != nil {
q.logDelivery(ctx, w.ID, event, string(payloadJSON), "failed", nil, stringPtr(err.Error()))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "WriteKit-Webhook/1.0")
if w.Secret != "" {
mac := hmac.New(sha256.New, []byte(w.Secret))
mac.Write(payloadJSON)
signature := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-WriteKit-Signature", signature)
}
resp, err := client.Do(req)
if err != nil {
q.logDelivery(ctx, w.ID, event, string(payloadJSON), "failed", nil, stringPtr(err.Error()))
q.updateWebhookStatus(ctx, w.ID, "failed")
return
}
defer resp.Body.Close()
var respBody string
buf := make([]byte, 1024)
n, _ := resp.Body.Read(buf)
respBody = string(buf[:n])
status := "success"
if resp.StatusCode >= 400 {
status = "failed"
}
q.logDelivery(ctx, w.ID, event, string(payloadJSON), status, &resp.StatusCode, &respBody)
q.updateWebhookStatus(ctx, w.ID, status)
}
func (q *Queries) logDelivery(ctx context.Context, webhookID, event, payload, status string, responseCode *int, responseBody *string) {
q.db.ExecContext(ctx, `
INSERT INTO webhook_deliveries (webhook_id, event, payload, status, response_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`, webhookID, event, truncate(payload, 1024), status, responseCode, truncate(ptrToString(responseBody), 1024))
// Cleanup old deliveries - keep last 50 per webhook
q.db.ExecContext(ctx, `
DELETE FROM webhook_deliveries
WHERE webhook_id = ? AND id NOT IN (
SELECT id FROM webhook_deliveries WHERE webhook_id = ?
ORDER BY created_at DESC LIMIT 50
)
`, webhookID, webhookID)
}
func (q *Queries) updateWebhookStatus(ctx context.Context, webhookID, status string) {
q.db.ExecContext(ctx, `
UPDATE webhooks SET last_triggered_at = CURRENT_TIMESTAMP, last_status = ?
WHERE id = ?
`, status, webhookID)
}
func (q *Queries) ListWebhookDeliveries(ctx context.Context, webhookID string) ([]WebhookDelivery, error) {
rows, err := q.db.QueryContext(ctx, `
SELECT id, webhook_id, event, payload, status, response_code, response_body, attempts, created_at
FROM webhook_deliveries
WHERE webhook_id = ?
ORDER BY created_at DESC
LIMIT 50
`, webhookID)
if err != nil {
return nil, err
}
defer rows.Close()
var deliveries []WebhookDelivery
for rows.Next() {
var d WebhookDelivery
var createdAtStr string
var respCode sql.NullInt64
var respBody sql.NullString
if err := rows.Scan(&d.ID, &d.WebhookID, &d.Event, &d.Payload, &d.Status, &respCode, &respBody, &d.Attempts, &createdAtStr); err != nil {
return nil, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
if respCode.Valid {
code := int(respCode.Int64)
d.ResponseCode = &code
}
if respBody.Valid {
d.ResponseBody = &respBody.String
}
deliveries = append(deliveries, d)
}
return deliveries, rows.Err()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
func stringPtr(s string) *string {
return &s
}
func ptrToString(p *string) string {
if p == nil {
return ""
}
return *p
}