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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

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

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

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

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

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

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

View file

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

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

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

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

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

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

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

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

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