init
54
internal/auth/middleware.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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, ¤cy, &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
|
|
@ -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
|
|
@ -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, "e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return "e, 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")
|
||||
}
|
||||
15
internal/build/assets/assets.go
Normal 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))
|
||||
}
|
||||
778
internal/build/assets/css/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
127
internal/build/assets/js/main.js
Normal 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;
|
||||
}
|
||||
}
|
||||
})();
|
||||
197
internal/build/assets/js/post.js
Normal 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 };
|
||||
})();
|
||||
76
internal/build/templates/base.html
Normal 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>© {{.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>
|
||||
34
internal/build/templates/blog.html
Normal 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">← Newer</a>
|
||||
{{end}}
|
||||
{{if .NextPage}}
|
||||
<a href="{{.NextPage}}" class="pagination-next">Older →</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
34
internal/build/templates/home.html
Normal 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}}
|
||||
57
internal/build/templates/post.html
Normal 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}}
|
||||
139
internal/build/templates/templates.go
Normal 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
|
||||
}
|
||||
451
internal/cloudflare/analytics.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
185
internal/db/migrations/001_initial.sql
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
77
internal/imaginary/client.go
Normal 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)
|
||||
}
|
||||
72
internal/markdown/markdown.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
126
internal/server/ratelimit.go
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
||||
BIN
internal/server/static/analytics-screenshot.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
internal/server/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
internal/server/static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
internal/server/static/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
internal/server/static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
internal/server/static/writekit-icon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
internal/server/static/writekit-icon.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
24
internal/server/static/writekit-icon.svg
Normal 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
113
internal/server/sync.go
Normal 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)
|
||||
}
|
||||
}
|
||||
64
internal/server/templates/404.html
Normal 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>
|
||||
56
internal/server/templates/expired.html
Normal 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>
|
||||
484
internal/server/templates/index.html
Normal 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">© 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>
|
||||
472
internal/server/templates/signup.html
Normal 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
|
|
@ -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)
|
||||
}
|
||||
14
internal/storage/storage.go
Normal 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
|
||||
}
|
||||
279
internal/tenant/analytics.go
Normal 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
|
||||
}
|
||||
94
internal/tenant/apikeys.go
Normal 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
|
|
@ -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}
|
||||
}
|
||||
70
internal/tenant/comments.go
Normal 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
|
||||
}
|
||||
63
internal/tenant/members.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
24
internal/tenant/queries.go
Normal 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}
|
||||
}
|
||||
159
internal/tenant/reactions.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
84
internal/tenant/settings.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||