init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
279
internal/tenant/analytics.go
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue