feat(backend): bootstrap Go module, config, and data plane

Introduce configuration validation, Postgres migrations, Redis/storage adapters, and the seed sample video.
This commit is contained in:
ahp
2026-10-07 19:40:26 +03:30
parent 5b9b2a16e6
commit f76c7a110b
40 changed files with 2289 additions and 0 deletions
+320
View File
@@ -0,0 +1,320 @@
package config
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Port string
DatabaseURL string
RedisURL string
JWTSecret string
PublicURL string
FrontendOrigins []string
TrustedProxyCIDRs []*net.IPNet
MetricsToken string
StorageDriver string
StorageDir string
StorageEndpoint string
StorageRegion string
StorageAccess string
StorageSecret string
StorageBucket string
HeartRegenHours int
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration // remember-me / long-lived refresh
RefreshTokenTTLSession time.Duration // short-lived refresh when rememberMe is false
MediaURLTTL time.Duration
DevMode bool
// CaptchaExposeAnswer returns plaintext captcha answers in GET /api/auth/captcha.
// Must never be enabled outside local tests (requires DevMode as well).
CaptchaExposeAnswer bool
SeedCurriculum bool
OTELEndpoint string
OTELServiceName string
DonationEnabled bool
DonationNote string
PhysicalCertEnabled bool
PhysicalCertPriceIRR int
PhysicalCertWindowDays int
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
// defaultTrustedProxyCIDRs covers loopback and typical Docker/private networks.
const defaultTrustedProxyCIDRs = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
func parseCIDRs(raw string) ([]*net.IPNet, error) {
parts := strings.Split(raw, ",")
out := make([]*net.IPNet, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
_, network, err := net.ParseCIDR(p)
if err != nil {
return nil, err
}
out = append(out, network)
}
return out, nil
}
func Load() *Config {
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" {
jwtSecret = developmentSecret()
}
// Fail closed: production-safe default unless explicitly enabled.
devMode := getEnv("DEV_MODE", "false") == "true"
origins := parseOrigins(getEnv("FRONTEND_ORIGINS", ""))
if len(origins) == 0 {
origins = []string{
"https://localhost", "https://127.0.0.1",
"http://localhost:3000", "http://127.0.0.1:3000",
"http://localhost:3001", "http://127.0.0.1:3001",
"http://localhost", "http://127.0.0.1",
}
}
seedCurriculum := getEnv("SEED_CURRICULUM", "") == "true"
if getEnv("SEED_CURRICULUM", "") == "" && devMode {
seedCurriculum = true
}
cidrs, err := parseCIDRs(getEnv("TRUSTED_PROXY_CIDRS", defaultTrustedProxyCIDRs))
if err != nil {
// Fall back to defaults rather than refusing to boot on a typo in optional env.
cidrs, _ = parseCIDRs(defaultTrustedProxyCIDRs)
}
return &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: resolveDatabaseURL(),
RedisURL: resolveRedisURL(),
JWTSecret: jwtSecret,
PublicURL: strings.TrimRight(getEnv("PUBLIC_URL", "https://localhost"), "/"),
FrontendOrigins: origins,
TrustedProxyCIDRs: cidrs,
MetricsToken: strings.TrimSpace(getEnv("METRICS_TOKEN", "")),
StorageDriver: getEnv("STORAGE_DRIVER", "s3"),
StorageDir: getEnv("STORAGE_DIR", "media"),
StorageEndpoint: getEnv("STORAGE_ENDPOINT", "http://rustfs:9000"),
StorageRegion: getEnv("STORAGE_REGION", "us-east-1"),
StorageAccess: getEnv("STORAGE_ACCESS_KEY", ""),
StorageSecret: getEnv("STORAGE_SECRET_KEY", ""),
StorageBucket: getEnv("STORAGE_BUCKET", "pargar"),
HeartRegenHours: getEnvInt("HEART_REGEN_HOURS", 4),
AccessTokenTTL: 15 * time.Minute,
RefreshTokenTTL: 30 * 24 * time.Hour,
RefreshTokenTTLSession: 24 * time.Hour,
MediaURLTTL: 2 * time.Hour,
DevMode: devMode,
CaptchaExposeAnswer: devMode && getEnv("CAPTCHA_EXPOSE_ANSWER", "") == "true",
SeedCurriculum: seedCurriculum,
OTELEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
OTELServiceName: getEnv("OTEL_SERVICE_NAME", "pargar-api"),
DonationEnabled: getEnv("DONATION_ENABLED", "true") == "true",
DonationNote: getEnv("DONATION_NOTE", "اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل می‌شود."),
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
}
}
// resolveDatabaseURL prefers DATABASE_URL; otherwise builds from POSTGRES_* with proper encoding.
func resolveDatabaseURL() string {
if u := strings.TrimSpace(os.Getenv("DATABASE_URL")); u != "" {
return u
}
pass := os.Getenv("POSTGRES_PASSWORD")
host := getEnv("POSTGRES_HOST", "")
if pass == "" || host == "" {
return "postgres://pargar:local-dev-only-password@localhost:5432/pargar?sslmode=disable"
}
user := getEnv("POSTGRES_USER", "pargar")
db := getEnv("POSTGRES_DB", "pargar")
ssl := getEnv("POSTGRES_SSLMODE", "disable")
port := getEnv("POSTGRES_PORT", "5432")
u := &url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: net.JoinHostPort(host, port),
Path: "/" + db,
}
q := u.Query()
q.Set("sslmode", ssl)
u.RawQuery = q.Encode()
return u.String()
}
// resolveRedisURL prefers REDIS_URL; otherwise builds from REDIS_HOST + REDIS_PASSWORD.
func resolveRedisURL() string {
if u := strings.TrimSpace(os.Getenv("REDIS_URL")); u != "" {
return u
}
host := getEnv("REDIS_HOST", "")
if host == "" {
return "redis://localhost:6379"
}
port := getEnv("REDIS_PORT", "6379")
pass := os.Getenv("REDIS_PASSWORD")
u := &url.URL{
Scheme: "redis",
Host: net.JoinHostPort(host, port),
}
if pass != "" {
u.User = url.UserPassword("", pass)
}
if db := strings.TrimSpace(os.Getenv("REDIS_DB")); db != "" {
u.Path = "/" + db
}
return u.String()
}
func parseOrigins(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, strings.TrimRight(p, "/"))
}
}
return out
}
var insecureSecretMarkers = []string{
"change-me",
"local-dev-only",
"insecure-default",
"password",
"secret",
"admin",
}
func isInsecureSecret(v string) bool {
s := strings.ToLower(strings.TrimSpace(v))
if s == "" {
return true
}
// Exact trivial values.
for _, m := range insecureSecretMarkers {
if s == m || s == m+"123" || s == m+"!" {
return true
}
}
// Known chart / compose placeholders.
placeholders := []string{"change-me", "local-dev-only", "insecure-default"}
for _, p := range placeholders {
if strings.Contains(s, p) {
return true
}
}
return false
}
func (c *Config) Validate() error {
if c.DevMode {
return nil
}
jwt := strings.TrimSpace(os.Getenv("JWT_SECRET"))
if len(jwt) < 32 {
return errors.New("JWT_SECRET must be at least 32 characters when DEV_MODE is false")
}
if isInsecureSecret(jwt) {
return errors.New("JWT_SECRET looks like a placeholder; set a strong random value")
}
adminPass := getEnv("ADMIN_PASSWORD", "")
if adminPass == "" {
return errors.New("ADMIN_PASSWORD must be set when DEV_MODE is false")
}
if isInsecureSecret(adminPass) || len(adminPass) < 12 {
return errors.New("ADMIN_PASSWORD looks weak or like a placeholder; set a strong value")
}
if c.StorageDriver == "s3" {
if c.StorageAccess == "" || c.StorageSecret == "" {
return errors.New("STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY must be set when DEV_MODE is false")
}
if isInsecureSecret(c.StorageSecret) {
return errors.New("STORAGE_SECRET_KEY looks like a placeholder; set a strong random value")
}
}
return nil
}
func (c *Config) AllowedOrigins() []string {
seen := map[string]bool{}
var out []string
add := func(o string) {
o = strings.TrimRight(strings.TrimSpace(o), "/")
if o == "" || seen[o] {
return
}
seen[o] = true
out = append(out, o)
}
for _, o := range c.FrontendOrigins {
add(o)
}
add(c.PublicURL)
return out
}
// IsTrustedProxy reports whether ip is allowed to supply X-Real-IP / X-Forwarded-For.
func (c *Config) IsTrustedProxy(ip net.IP) bool {
if ip == nil {
return false
}
for _, network := range c.TrustedProxyCIDRs {
if network.Contains(ip) {
return true
}
}
return false
}
// CookieSecure reports whether session cookies should set the Secure flag.
func (c *Config) CookieSecure() bool {
return strings.HasPrefix(strings.ToLower(c.PublicURL), "https://")
}
func developmentSecret() string {
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
panic("could not generate development JWT secret")
}
return hex.EncodeToString(raw[:])
}
// MustValidate is a helper for tests asserting Validate messages.
func FormatValidateError(err error) string {
if err == nil {
return ""
}
return fmt.Sprintf("%v", err)
}
+74
View File
@@ -0,0 +1,74 @@
package config
import (
"os"
"strings"
"testing"
)
func TestValidateRejectsPlaceholderSecrets(t *testing.T) {
t.Setenv("DEV_MODE", "false")
t.Setenv("JWT_SECRET", "change-me-to-at-least-32-characters-long")
t.Setenv("ADMIN_PASSWORD", "change-me-strong-admin-password")
t.Setenv("STORAGE_ACCESS_KEY", "ak")
t.Setenv("STORAGE_SECRET_KEY", "change-me-long-rustfs-secret")
cfg := Load()
cfg.DevMode = false
cfg.StorageDriver = "s3"
cfg.StorageAccess = "ak"
cfg.StorageSecret = "change-me-long-rustfs-secret"
if err := cfg.Validate(); err == nil {
t.Fatal("expected Validate to reject placeholder JWT")
}
}
func TestValidateAcceptsStrongSecrets(t *testing.T) {
t.Setenv("DEV_MODE", "false")
jwt := strings.Repeat("a1B2c3D4", 6) // 48 chars, no placeholder markers
t.Setenv("JWT_SECRET", jwt)
t.Setenv("ADMIN_PASSWORD", "Tr0ub4dor&3-not-default")
cfg := Load()
cfg.DevMode = false
cfg.StorageDriver = "s3"
cfg.StorageAccess = "AKIAEXAMPLE"
cfg.StorageSecret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
}
func TestResolveDatabaseURLEncodesPassword(t *testing.T) {
os.Unsetenv("DATABASE_URL")
t.Setenv("POSTGRES_USER", "pargar")
t.Setenv("POSTGRES_PASSWORD", "p@ss:word/with%chars")
t.Setenv("POSTGRES_HOST", "db.internal")
t.Setenv("POSTGRES_DB", "pargar")
t.Setenv("POSTGRES_SSLMODE", "disable")
u := resolveDatabaseURL()
if !strings.Contains(u, "p%40ss%3Aword%2Fwith%25chars") {
t.Fatalf("password not encoded in URL: %s", u)
}
if !strings.Contains(u, "@db.internal:") {
t.Fatalf("host missing: %s", u)
}
}
func TestResolveRedisURLEncodesPassword(t *testing.T) {
os.Unsetenv("REDIS_URL")
t.Setenv("REDIS_HOST", "redis.internal")
t.Setenv("REDIS_PASSWORD", "r@dis:pass")
u := resolveRedisURL()
if !strings.Contains(u, "%40") || !strings.Contains(u, "%3A") {
t.Fatalf("redis password not encoded: %s", u)
}
}
func TestIsInsecureSecret(t *testing.T) {
if !isInsecureSecret("change-me-postgres") {
t.Fatal("expected change-me to be insecure")
}
if isInsecureSecret(strings.Repeat("xY9k", 12)) {
t.Fatal("random-looking secret should be ok")
}
}
+19
View File
@@ -0,0 +1,19 @@
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, fmt.Errorf("connect db: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return pool, nil
}
+8
View File
@@ -0,0 +1,8 @@
package db
import "embed"
// MigrationFiles is the embedded set of .sql migration files.
//
//go:embed migrations/*.sql
var MigrationFiles embed.FS
+61
View File
@@ -0,0 +1,61 @@
package db
import (
"context"
"fmt"
"io/fs"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Migrate runs all *.sql files in the migrations fs in lexicographic order.
// Tracks applied migrations in schema_migrations.
func Migrate(ctx context.Context, pool *pgxpool.Pool, migrations fs.FS) error {
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
entries, err := fs.ReadDir(migrations, ".")
if err != nil {
return err
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
applied, err := pool.Exec(ctx, `SELECT 1 FROM schema_migrations WHERE version=$1`, name)
if err != nil {
return err
}
if applied.RowsAffected() > 0 {
continue
}
body, err := fs.ReadFile(migrations, name)
if err != nil {
return err
}
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
if _, err := tx.Exec(ctx, string(body)); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("apply %s: %w", name, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil {
_ = tx.Rollback(ctx)
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,205 @@
-- 0001_init.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TYPE user_role AS ENUM ('student', 'mentor', 'admin');
CREATE TYPE notif_category AS ENUM ('progress', 'gamification', 'mentor', 'event');
CREATE TYPE quiz_status AS ENUM ('in_progress', 'passed', 'failed');
CREATE TYPE event_type AS ENUM ('meet', 'zoom', 'workshop');
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role user_role NOT NULL DEFAULT 'student',
xp INT NOT NULL DEFAULT 0,
hearts INT NOT NULL DEFAULT 3,
hearts_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
streak_current INT NOT NULL DEFAULT 0,
streak_longest INT NOT NULL DEFAULT 0,
last_activity_date DATE,
is_locked BOOLEAN NOT NULL DEFAULT false,
locked_at TIMESTAMPTZ,
unlocked_by BIGINT REFERENCES users(id),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE chapters (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT 'leaf',
sort_order INT NOT NULL DEFAULT 0
);
CREATE TABLE lessons (
id BIGSERIAL PRIMARY KEY,
chapter_id BIGINT NOT NULL REFERENCES chapters(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
video_key TEXT NOT NULL DEFAULT '',
duration_seconds INT NOT NULL DEFAULT 0,
completion_threshold_pct INT NOT NULL DEFAULT 85,
requires_lesson_id BIGINT REFERENCES lessons(id),
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
xp_reward INT NOT NULL DEFAULT 50,
is_active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE mcq_questions (
id BIGSERIAL PRIMARY KEY,
lesson_id BIGINT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
position INT NOT NULL DEFAULT 0,
question TEXT NOT NULL,
options JSONB NOT NULL,
answer_index INT NOT NULL,
explanation TEXT NOT NULL DEFAULT ''
);
CREATE TABLE watch_sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
lesson_id BIGINT NOT NULL REFERENCES lessons(id),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_position FLOAT NOT NULL DEFAULT 0,
UNIQUE (user_id, lesson_id)
);
CREATE TABLE watch_heartbeats (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
lesson_id BIGINT NOT NULL REFERENCES lessons(id),
session_id BIGINT NOT NULL REFERENCES watch_sessions(id),
position FLOAT NOT NULL,
delta FLOAT NOT NULL,
seq INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, lesson_id, seq)
);
CREATE TABLE lesson_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
lesson_id BIGINT NOT NULL REFERENCES lessons(id),
watched_seconds FLOAT NOT NULL DEFAULT 0,
watched_pct FLOAT NOT NULL DEFAULT 0,
last_position FLOAT NOT NULL DEFAULT 0,
quiz_unlocked BOOLEAN NOT NULL DEFAULT false,
passed_quiz BOOLEAN NOT NULL DEFAULT false,
quiz_completed_at TIMESTAMPTZ,
UNIQUE (user_id, lesson_id)
);
CREATE TABLE quiz_attempts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
lesson_id BIGINT NOT NULL REFERENCES lessons(id),
answers JSONB NOT NULL DEFAULT '[]',
correct_count INT NOT NULL DEFAULT 0,
total INT NOT NULL DEFAULT 0,
score_pct FLOAT NOT NULL DEFAULT 0,
status quiz_status NOT NULL DEFAULT 'in_progress',
hearts_lost INT NOT NULL DEFAULT 0,
passed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE xp_events (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
amount INT NOT NULL,
source TEXT NOT NULL,
ref_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE weekly_leaderboard (
week TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id),
xp INT NOT NULL DEFAULT 0,
PRIMARY KEY (week, user_id)
);
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
event_type event_type NOT NULL DEFAULT 'meet',
external_url TEXT NOT NULL DEFAULT '',
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
ics_uuid UUID NOT NULL DEFAULT gen_random_uuid(),
is_active BOOLEAN NOT NULL DEFAULT true,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE event_rsvps (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (event_id, user_id)
);
CREATE TABLE chat_messages (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
mentor_id BIGINT NOT NULL REFERENCES users(id),
sender_role user_role NOT NULL DEFAULT 'student',
body TEXT NOT NULL,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
category notif_category NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
route TEXT NOT NULL DEFAULT '',
data JSONB NOT NULL DEFAULT '{}',
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_notifications_user ON notifications (user_id, created_at DESC);
CREATE TABLE notification_prefs (
user_id BIGINT PRIMARY KEY REFERENCES users(id),
progress BOOLEAN NOT NULL DEFAULT true,
gamification BOOLEAN NOT NULL DEFAULT true,
mentor BOOLEAN NOT NULL DEFAULT true,
event BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE video_assets (
id BIGSERIAL PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
mime TEXT NOT NULL DEFAULT 'video/mp4',
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
token_hash TEXT UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE reminders_sent (
user_id BIGINT NOT NULL REFERENCES users(id),
kind TEXT NOT NULL,
ref TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, kind, ref)
);
@@ -0,0 +1,5 @@
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS attachment_type TEXT,
ADD COLUMN IF NOT EXISTS attachment_url TEXT,
ADD COLUMN IF NOT EXISTS attachment_name TEXT,
ADD COLUMN IF NOT EXISTS attachment_size BIGINT;
@@ -0,0 +1 @@
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS reply_to BIGINT;
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS chat_reactions (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(message_id, user_id, emoji)
);
CREATE INDEX IF NOT EXISTS idx_chat_reactions_msg ON chat_reactions(message_id);
@@ -0,0 +1,3 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_variant TEXT NOT NULL DEFAULT 'beam';
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_palette TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_photo TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,19 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT;
UPDATE users
SET username = lower(split_part(email, '@', 1))
WHERE username IS NULL OR username = '';
UPDATE users u
SET username = u.username || '-' || u.id::text
WHERE u.id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY id) AS rn
FROM users
) ranked
WHERE ranked.rn > 1
);
ALTER TABLE users ALTER COLUMN username SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS users_username_uidx ON users (lower(username));
@@ -0,0 +1,10 @@
-- Keep the database invariant aligned with the application-level hearts guard.
UPDATE users
SET hearts = 3,
is_locked = true,
locked_at = COALESCE(locked_at, now()),
hearts_updated_at = now()
WHERE hearts > 3;
ALTER TABLE users
ADD CONSTRAINT users_hearts_max CHECK (hearts BETWEEN 0 AND 3);
@@ -0,0 +1,8 @@
-- Hot-path indexes and heartbeat retention helpers.
CREATE INDEX IF NOT EXISTS idx_quiz_attempts_user_lesson ON quiz_attempts (user_id, lesson_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_chat_messages_pair ON chat_messages (user_id, mentor_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_lesson_progress_user ON lesson_progress (user_id);
CREATE INDEX IF NOT EXISTS idx_watch_heartbeats_created ON watch_heartbeats (created_at);
CREATE INDEX IF NOT EXISTS idx_events_starts ON events (starts_at) WHERE is_active;
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens (user_id) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_xp_events_user ON xp_events (user_id, created_at DESC);
@@ -0,0 +1,54 @@
-- Identity, Telegram, security Q/A, certificates, physical orders
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_id BIGINT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_username TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS security_question TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS security_answer_hash TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS users_phone_uidx
ON users (phone) WHERE phone <> '';
CREATE UNIQUE INDEX IF NOT EXISTS users_telegram_id_uidx
ON users (telegram_id) WHERE telegram_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS certificates (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_id TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
UNIQUE (user_id)
);
CREATE INDEX IF NOT EXISTS certificates_public_id_idx ON certificates (public_id);
CREATE TYPE physical_order_status AS ENUM ('requested', 'paid', 'shipped', 'cancelled');
CREATE TABLE IF NOT EXISTS certificate_physical_orders (
id BIGSERIAL PRIMARY KEY,
certificate_id BIGINT NOT NULL REFERENCES certificates(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status physical_order_status NOT NULL DEFAULT 'requested',
note TEXT NOT NULL DEFAULT '',
window_ends_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS cert_physical_orders_user_idx
ON certificate_physical_orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS cert_physical_orders_status_idx
ON certificate_physical_orders (status, created_at DESC);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
INSERT INTO app_settings (key, value) VALUES
('physical_cert_enabled', 'true'),
('physical_cert_price_irr', '2500000'),
('physical_cert_window_days', '30')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,5 @@
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_chat_messages_pinned
ON chat_messages (user_id, mentor_id, pinned_at DESC)
WHERE pinned_at IS NOT NULL;
@@ -0,0 +1,24 @@
-- Shipping details + tracking for physical certificates
ALTER TABLE certificate_physical_orders
ADD COLUMN IF NOT EXISTS recipient_name TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS address TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS city TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS postal_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS tracking_code TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS unlock_requests (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS unlock_requests_user_created_idx
ON unlock_requests (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS unlock_requests_open_idx
ON unlock_requests (created_at DESC)
WHERE resolved_at IS NULL;
@@ -0,0 +1,9 @@
-- Public/community settings defaults + sponsors (admin-editable)
INSERT INTO app_settings (key, value) VALUES
('telegram_channel_username', ''),
('telegram_channel_title', 'کانال پرگار'),
('donation_enabled', 'true'),
('donation_note', 'اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل می‌شود.'),
('sponsors', '[{"name":"نمونه اسپانسر","url":"https://example.com","blurb":"حامی یادگیری واقعی در پرگار"}]')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,13 @@
-- Gated public registration: IP one-shot + admin toggle
CREATE TABLE IF NOT EXISTS registration_ips (
ip TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS registration_ips_user_idx ON registration_ips (user_id);
INSERT INTO app_settings (key, value) VALUES
('registration_enabled', 'false')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,19 @@
-- Capacity-limited registration invite / membership links
CREATE TABLE IF NOT EXISTS registration_invites (
id BIGSERIAL PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
token_enc TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
max_uses INT NOT NULL CHECK (max_uses > 0),
used_count INT NOT NULL DEFAULT 0 CHECK (used_count >= 0),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'revoked', 'exhausted')),
expires_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT registration_invites_used_lte_max CHECK (used_count <= max_uses)
);
CREATE INDEX IF NOT EXISTS registration_invites_status_idx
ON registration_invites (status, created_at DESC);
@@ -0,0 +1,19 @@
-- One-shot phone whitelist for public registration
CREATE TABLE IF NOT EXISTS registration_phone_whitelist (
id BIGSERIAL PRIMARY KEY,
phone TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'available'
CHECK (status IN ('available', 'consumed')),
consumed_at TIMESTAMPTZ,
consumed_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT registration_phone_whitelist_consumed_consistency CHECK (
(status = 'available' AND consumed_at IS NULL AND consumed_user_id IS NULL)
OR (status = 'consumed' AND consumed_at IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS registration_phone_whitelist_status_idx
ON registration_phone_whitelist (status, created_at DESC);
@@ -0,0 +1,8 @@
-- Allow temporarily pausing registration invites without revoking them
ALTER TABLE registration_invites
DROP CONSTRAINT IF EXISTS registration_invites_status_check;
ALTER TABLE registration_invites
ADD CONSTRAINT registration_invites_status_check
CHECK (status IN ('active', 'paused', 'revoked', 'exhausted'));
@@ -0,0 +1,11 @@
-- Phone blacklist: blocks all registration (public and invite)
CREATE TABLE IF NOT EXISTS registration_phone_blacklist (
id BIGSERIAL PRIMARY KEY,
phone TEXT NOT NULL UNIQUE,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS registration_phone_blacklist_created_at_idx
ON registration_phone_blacklist (created_at DESC);
@@ -0,0 +1,19 @@
-- Audit log when a blacklisted phone tries to register (public or invite).
CREATE TABLE IF NOT EXISTS registration_blacklist_attempts (
id BIGSERIAL PRIMARY KEY,
phone TEXT NOT NULL,
path TEXT NOT NULL CHECK (path IN ('public', 'invite')),
invite_id BIGINT REFERENCES registration_invites(id) ON DELETE SET NULL,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
attempted_username TEXT NOT NULL DEFAULT '',
attempted_email TEXT NOT NULL DEFAULT '',
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS registration_blacklist_attempts_phone_idx
ON registration_blacklist_attempts (phone);
CREATE INDEX IF NOT EXISTS registration_blacklist_attempts_attempted_at_idx
ON registration_blacklist_attempts (attempted_at DESC);
@@ -0,0 +1,17 @@
-- Time-limited, revocable download tokens for project-wide chat export links.
CREATE TABLE IF NOT EXISTS chat_export_tokens (
id BIGSERIAL PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
token_enc TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS chat_export_tokens_active_idx
ON chat_export_tokens (expires_at DESC)
WHERE revoked_at IS NULL;
@@ -0,0 +1,8 @@
-- Chat export download links are single-use after a successful download.
ALTER TABLE chat_export_tokens
ADD COLUMN IF NOT EXISTS used_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS chat_export_tokens_used_idx
ON chat_export_tokens (used_at)
WHERE used_at IS NOT NULL;
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS challenges (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
target_xp INT NOT NULL CHECK (target_xp > 0),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (ends_at >= starts_at)
);
CREATE INDEX IF NOT EXISTS idx_challenges_active_window
ON challenges (is_active, starts_at, ends_at);
CREATE TABLE IF NOT EXISTS challenge_progress (
challenge_id BIGINT NOT NULL REFERENCES challenges(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
xp INT NOT NULL DEFAULT 0 CHECK (xp >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (challenge_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_challenge_progress_user ON challenge_progress (user_id);
+330
View File
@@ -0,0 +1,330 @@
package models
import (
"encoding/json"
"time"
)
type Role string
const (
RoleStudent Role = "student"
RoleMentor Role = "mentor"
RoleAdmin Role = "admin"
)
func (r Role) String() string { return string(r) }
// IsStaff reports whether the role is exempt from student heart/lockout limits.
func (r Role) IsStaff() bool {
return r == RoleAdmin || r == RoleMentor
}
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Name string `json:"name"`
Role Role `json:"role"`
XP int `json:"xp"`
Hearts int `json:"hearts"`
HeartsUpdatedAt time.Time `json:"heartsUpdatedAt"`
StreakCurrent int `json:"streakCurrent"`
StreakLongest int `json:"streakLongest"`
LastActivityDate *time.Time `json:"lastActivityDate,omitempty"`
IsLocked bool `json:"isLocked"`
LockedAt *time.Time `json:"lockedAt,omitempty"`
UnlockedBy *int64 `json:"unlockedBy,omitempty"`
IsActive bool `json:"isActive"`
AvatarVariant string `json:"avatarVariant"`
AvatarPalette string `json:"avatarPalette"`
AvatarPhoto string `json:"avatarPhoto,omitempty"`
Phone string `json:"phone"`
TelegramID *int64 `json:"-"`
TelegramUsername string `json:"-"`
SecurityQuestion string `json:"securityQuestion,omitempty"`
SecurityAnswerHash string `json:"-"`
HasSecurityAnswer bool `json:"hasSecurityAnswer"`
CreatedAt time.Time `json:"createdAt"`
}
type Certificate struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
PublicID string `json:"publicId"`
FullName string `json:"fullName"`
IssuedAt time.Time `json:"issuedAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
}
type PhysicalOrderStatus string
const (
PhysicalRequested PhysicalOrderStatus = "requested"
PhysicalPaid PhysicalOrderStatus = "paid"
PhysicalShipped PhysicalOrderStatus = "shipped"
PhysicalCancelled PhysicalOrderStatus = "cancelled"
)
type CertificatePhysicalOrder struct {
ID int64 `json:"id"`
CertificateID int64 `json:"certificateId"`
UserID int64 `json:"userId"`
Status PhysicalOrderStatus `json:"status"`
Note string `json:"note"`
RecipientName string `json:"recipientName"`
Phone string `json:"phone"`
Address string `json:"address"`
City string `json:"city"`
PostalCode string `json:"postalCode"`
TrackingCode string `json:"trackingCode"`
WindowEndsAt time.Time `json:"windowEndsAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
UserName string `json:"userName,omitempty"`
PublicID string `json:"publicId,omitempty"`
}
type UnlockRequest struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
Note string `json:"note"`
CreatedAt time.Time `json:"createdAt"`
ResolvedAt *time.Time `json:"resolvedAt,omitempty"`
UserName string `json:"userName,omitempty"`
}
type NotificationPrefs struct {
UserID int64 `json:"userId"`
Progress bool `json:"progress"`
Gamification bool `json:"gamification"`
Mentor bool `json:"mentor"`
Event bool `json:"event"`
}
type Chapter struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
SortOrder int `json:"sortOrder"`
}
type Lesson struct {
ID int64 `json:"id"`
ChapterID int64 `json:"chapterId"`
Title string `json:"title"`
Description string `json:"description"`
VideoKey string `json:"videoKey"`
DurationSeconds int `json:"durationSeconds"`
CompletionThresholdPct int `json:"completionThresholdPct"`
RequiresLessonID *int64 `json:"requiresLessonId,omitempty"`
X int `json:"x"`
Y int `json:"y"`
SortOrder int `json:"sortOrder"`
XPReward int `json:"xpReward"`
IsActive bool `json:"isActive"`
}
type MCQQuestion struct {
ID int64 `json:"id"`
LessonID int64 `json:"lessonId"`
Position int `json:"position"`
Question string `json:"question"`
Options []string `json:"options"`
AnswerIndex int `json:"answerIndex"`
Explanation string `json:"explanation"`
}
type LessonProgress struct {
UserID int64 `json:"userId"`
LessonID int64 `json:"lessonId"`
WatchedSeconds float64 `json:"watchedSeconds"`
WatchedPct float64 `json:"watchedPct"`
LastPosition float64 `json:"lastPosition"`
QuizUnlocked bool `json:"quizUnlocked"`
PassedQuiz bool `json:"passedQuiz"`
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
}
type WatchSession struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
LessonID int64 `json:"lessonId"`
StartedAt time.Time `json:"startedAt"`
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
LastPosition float64 `json:"lastPosition"`
}
type QuizQuestionView struct {
ID int64 `json:"id"`
Position int `json:"position"`
Question string `json:"question"`
Options []string `json:"options"`
}
type QuizAttempt struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
LessonID int64 `json:"lessonId"`
Answers []int `json:"-"`
CorrectCount int `json:"correctCount"`
Total int `json:"total"`
ScorePct float64 `json:"scorePct"`
Status string `json:"status"`
HeartsLost int `json:"heartsLost"`
PassedAt *time.Time `json:"passedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type Event struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
EventType string `json:"eventType"`
ExternalURL string `json:"externalUrl"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy *int64 `json:"-"`
Rsvped bool `json:"rsvped,omitempty"`
}
// Challenge is a timed XP goal (typically monthly) for students.
type Challenge struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
TargetXP int `json:"targetXp"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy *int64 `json:"-"`
// Progress fields for the requesting student (optional).
MyXP int `json:"myXp,omitempty"`
Completed bool `json:"completed,omitempty"`
}
type ChatMessage struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
MentorID int64 `json:"mentorId"`
SenderRole Role `json:"senderRole"`
Body string `json:"body"`
ReadAt *time.Time `json:"readAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ReplyTo *int64 `json:"replyTo,omitempty"`
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
Pinned bool `json:"pinned"`
Attachment *Attachment `json:"attachment,omitempty"`
Reactions []ChatReaction `json:"reactions,omitempty"`
}
type ChatReaction struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
Mine bool `json:"mine"`
}
type Attachment struct {
Type string `json:"type"` // image | video | audio | file
URL string `json:"url"`
Name string `json:"name"`
Size int64 `json:"size"`
}
type Notification struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
Category string `json:"category"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
Route string `json:"route"`
Data json.RawMessage `json:"data"`
ReadAt *time.Time `json:"readAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type InviteStatus string
const (
InviteActive InviteStatus = "active"
InvitePaused InviteStatus = "paused"
InviteRevoked InviteStatus = "revoked"
InviteExhausted InviteStatus = "exhausted"
)
// RegistrationInvite is a capacity-limited membership registration link.
type RegistrationInvite struct {
ID int64 `json:"id"`
Label string `json:"label"`
MaxUses int `json:"maxUses"`
UsedCount int `json:"usedCount"`
Remaining int `json:"remaining"`
Status InviteStatus `json:"status"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
CreatedBy *int64 `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
// Token is the raw invite token; only populated when decrypting for admin URL display.
Token string `json:"token,omitempty"`
URL string `json:"url,omitempty"`
}
// ChatExportToken is a time-limited, single-use download link for the project chat archive.
type ChatExportToken struct {
ID int64 `json:"id"`
Label string `json:"label"`
ExpiresAt time.Time `json:"expiresAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
UsedAt *time.Time `json:"usedAt,omitempty"`
CreatedBy *int64 `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
Active bool `json:"active"`
// Token/URL are populated for admin display after decrypt.
Token string `json:"token,omitempty"`
URL string `json:"url,omitempty"`
}
type WhitelistStatus string
const (
WhitelistAvailable WhitelistStatus = "available"
WhitelistConsumed WhitelistStatus = "consumed"
)
// RegistrationPhoneWhitelist is a one-shot phone allowed for public registration.
type RegistrationPhoneWhitelist struct {
ID int64 `json:"id"`
Phone string `json:"phone"`
Status WhitelistStatus `json:"status"`
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
ConsumedUserID *int64 `json:"consumedUserId,omitempty"`
CreatedBy *int64 `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// RegistrationPhoneBlacklist blocks a phone from all registration paths.
type RegistrationPhoneBlacklist struct {
ID int64 `json:"id"`
Phone string `json:"phone"`
CreatedBy *int64 `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// RegistrationBlacklistAttempt records a failed registration try with a blacklisted phone.
type RegistrationBlacklistAttempt struct {
ID int64 `json:"id"`
Phone string `json:"phone"`
Path string `json:"path"` // public | invite
InviteID *int64 `json:"inviteId,omitempty"`
IP string `json:"ip"`
UserAgent string `json:"userAgent,omitempty"`
AttemptedUsername string `json:"attemptedUsername,omitempty"`
AttemptedEmail string `json:"attemptedEmail,omitempty"`
AttemptedAt time.Time `json:"attemptedAt"`
}
+219
View File
@@ -0,0 +1,219 @@
package observability
import (
"fmt"
"net/http"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// Lightweight in-process Prometheus text metrics (no promhttp / compress deps).
type counterVec struct {
mu sync.Mutex
name string
help string
labels []string
values map[string]*atomic.Int64
}
func newCounterVec(name, help string, labels []string) *counterVec {
return &counterVec{
name: name,
help: help,
labels: labels,
values: map[string]*atomic.Int64{},
}
}
func (c *counterVec) WithLabelValues(vals ...string) *atomic.Int64 {
key := strings.Join(vals, "\x00")
c.mu.Lock()
defer c.mu.Unlock()
if v, ok := c.values[key]; ok {
return v
}
v := &atomic.Int64{}
c.values[key] = v
return v
}
func (c *counterVec) Inc(vals ...string) {
c.WithLabelValues(vals...).Add(1)
}
func (c *counterVec) write(b *strings.Builder) {
c.mu.Lock()
defer c.mu.Unlock()
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s counter\n", c.name, c.help, c.name)
keys := make([]string, 0, len(c.values))
for k := range c.values {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
parts := strings.Split(k, "\x00")
var labelPairs []string
for i, lab := range c.labels {
val := ""
if i < len(parts) {
val = parts[i]
}
labelPairs = append(labelPairs, fmt.Sprintf(`%s="%s"`, lab, escapeLabel(val)))
}
fmt.Fprintf(b, "%s{%s} %d\n", c.name, strings.Join(labelPairs, ","), c.values[k].Load())
}
}
type counter struct {
name string
help string
value atomic.Int64
}
func newCounter(name, help string) *counter {
return &counter{name: name, help: help}
}
func (c *counter) Inc() { c.value.Add(1) }
func (c *counter) write(b *strings.Builder) {
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", c.name, c.help, c.name, c.name, c.value.Load())
}
type histVec struct {
mu sync.Mutex
name string
help string
buckets []float64
obs map[string]*histObs
}
type histObs struct {
counts []atomic.Uint64
sum atomic.Uint64 // milliseconds * 1000 for precision via float bits — use float64 via mutex instead
sumMu sync.Mutex
sumF float64
count atomic.Uint64
}
func newHistVec(name, help string, buckets []float64) *histVec {
return &histVec{
name: name,
help: help,
buckets: buckets,
obs: map[string]*histObs{},
}
}
func (h *histVec) Observe(method, path string, seconds float64) {
key := method + "\x00" + path
h.mu.Lock()
o, ok := h.obs[key]
if !ok {
o = &histObs{counts: make([]atomic.Uint64, len(h.buckets))}
h.obs[key] = o
}
h.mu.Unlock()
for i, b := range h.buckets {
if seconds <= b {
o.counts[i].Add(1)
}
}
o.count.Add(1)
o.sumMu.Lock()
o.sumF += seconds
o.sumMu.Unlock()
}
func (h *histVec) write(b *strings.Builder) {
h.mu.Lock()
defer h.mu.Unlock()
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s histogram\n", h.name, h.help, h.name)
keys := make([]string, 0, len(h.obs))
for k := range h.obs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
parts := strings.Split(k, "\x00")
method, path := parts[0], ""
if len(parts) > 1 {
path = parts[1]
}
base := fmt.Sprintf(`method="%s",path="%s"`, escapeLabel(method), escapeLabel(path))
o := h.obs[k]
for i, bound := range h.buckets {
fmt.Fprintf(b, `%s_bucket{%s,le="%g"} %d`+"\n", h.name, base, bound, o.counts[i].Load())
}
fmt.Fprintf(b, `%s_bucket{%s,le="+Inf"} %d`+"\n", h.name, base, o.count.Load())
o.sumMu.Lock()
sum := o.sumF
o.sumMu.Unlock()
fmt.Fprintf(b, "%s_sum{%s} %g\n", h.name, base, sum)
fmt.Fprintf(b, "%s_count{%s} %d\n", h.name, base, o.count.Load())
}
}
func escapeLabel(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
return s
}
var (
httpRequests = newCounterVec("pargar_http_requests_total", "Total HTTP requests", []string{"method", "path", "status"})
httpDuration = newHistVec("pargar_http_request_duration_seconds", "HTTP request duration", []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10})
quizSubmits = newCounterVec("pargar_quiz_submits_total", "Quiz submit outcomes", []string{"result"})
lockouts = newCounter("pargar_account_lockouts_total", "Accounts locked after heart depletion")
heartbeats = newCounter("pargar_watch_heartbeats_total", "Accepted watch heartbeats")
videoUploads = newCounter("pargar_video_uploads_total", "Admin video uploads")
logins = newCounterVec("pargar_auth_logins_total", "Login attempts", []string{"result"})
)
// Compatibility wrappers matching previous call sites.
type labeledInc struct{ vec *counterVec }
func (l labeledInc) WithLabelValues(vals ...string) interface{ Inc() } {
return labeledCounter{c: l.vec.WithLabelValues(vals...)}
}
type labeledCounter struct{ c *atomic.Int64 }
func (l labeledCounter) Inc() { l.c.Add(1) }
var (
HTTPRequests = labeledInc{httpRequests}
QuizSubmits = labeledInc{quizSubmits}
Logins = labeledInc{logins}
Lockouts = lockouts
Heartbeats = heartbeats
VideoUploads = videoUploads
)
// ObserveHTTP records request metrics.
func ObserveHTTP(method, path string, status int, d time.Duration) {
httpRequests.Inc(method, path, fmt.Sprintf("%d", status))
httpDuration.Observe(method, path, d.Seconds())
}
// MetricsHandler serves Prometheus text exposition.
func MetricsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b strings.Builder
httpRequests.write(&b)
httpDuration.write(&b)
quizSubmits.write(&b)
lockouts.write(&b)
heartbeats.write(&b)
videoUploads.write(&b)
logins.write(&b)
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
_, _ = w.Write([]byte(b.String()))
})
}
@@ -0,0 +1,111 @@
package observability
import (
"context"
"log/slog"
"os"
"strings"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
)
// Config holds optional OpenTelemetry settings.
type Config struct {
ServiceName string
OTLPEndpoint string // empty = noop tracer
}
var (
logger *slog.Logger
tracer trace.Tracer = noop.NewTracerProvider().Tracer("pargar")
)
// SetupJSONLogger installs a JSON slog default logger.
func SetupJSONLogger() *slog.Logger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
logger = slog.New(handler)
slog.SetDefault(logger)
return logger
}
func L() *slog.Logger {
if logger == nil {
return slog.Default()
}
return logger
}
// Activity emits a structured domain activity log line.
func Activity(action string, attrs ...any) {
args := make([]any, 0, len(attrs)+2)
args = append(args, "action", action)
args = append(args, attrs...)
L().Info("activity", args...)
}
// SetupTracing configures OTLP HTTP exporter when endpoint is set; otherwise noop.
// Returns a shutdown func (always non-nil).
func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error, error) {
name := strings.TrimSpace(cfg.ServiceName)
if name == "" {
name = "pargar-api"
}
endpoint := strings.TrimSpace(cfg.OTLPEndpoint)
if endpoint == "" {
tp := noop.NewTracerProvider()
otel.SetTracerProvider(tp)
tracer = tp.Tracer(name)
L().Info("otel tracing disabled (no OTEL_EXPORTER_OTLP_ENDPOINT)")
return func(context.Context) error { return nil }, nil
}
endpoint = strings.TrimPrefix(endpoint, "https://")
endpoint = strings.TrimPrefix(endpoint, "http://")
exp, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(endpoint),
otlptracehttp.WithInsecure(),
)
if err != nil {
return nil, err
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(name),
),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
tracer = tp.Tracer(name)
L().Info("otel tracing enabled", "endpoint", endpoint, "service", name)
return tp.Shutdown, nil
}
func Tracer() trace.Tracer { return tracer }
// StartSpan starts a named span from context.
func StartSpan(ctx context.Context, name string) (context.Context, trace.Span) {
return Tracer().Start(ctx, name)
}
// DurationMs helper for middleware logs.
func DurationMs(start time.Time) int64 {
return time.Since(start).Milliseconds()
}
@@ -0,0 +1,19 @@
package observability
import "testing"
func TestSetupTracingNoop(t *testing.T) {
shutdown, err := SetupTracing(t.Context(), Config{ServiceName: "test", OTLPEndpoint: ""})
if err != nil {
t.Fatal(err)
}
if shutdown == nil {
t.Fatal("expected shutdown func")
}
if err := shutdown(t.Context()); err != nil {
t.Fatal(err)
}
ctx, span := StartSpan(t.Context(), "test.span")
_ = ctx
span.End()
}
+26
View File
@@ -0,0 +1,26 @@
package redisdb
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
type Client struct {
rdb *redis.Client
}
func Connect(ctx context.Context, url string) (*Client, error) {
opts, err := redis.ParseURL(url)
if err != nil {
return nil, fmt.Errorf("parse redis url: %w", err)
}
rdb := redis.NewClient(opts)
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("ping redis: %w", err)
}
return &Client{rdb: rdb}, nil
}
func (c *Client) RDB() *redis.Client { return c.rdb }
+56
View File
@@ -0,0 +1,56 @@
package storage
import (
"fmt"
"os"
"path/filepath"
)
const SampleVideoKey = "sample.mp4"
// EnsureSampleMP4 uploads the color-bars sample video when the storage key is missing.
// candidates are filesystem paths checked in order (first readable wins).
func EnsureSampleMP4(stg Storage, candidates ...string) error {
if stg == nil {
return fmt.Errorf("storage is nil")
}
if stg.Exists(SampleVideoKey) {
return nil
}
for _, path := range candidates {
if path == "" {
continue
}
f, err := os.Open(path)
if err != nil {
continue
}
info, err := f.Stat()
if err != nil {
f.Close()
continue
}
if info.IsDir() || info.Size() == 0 {
f.Close()
continue
}
_, err = stg.Put(SampleVideoKey, f, info.Size())
f.Close()
if err != nil {
return fmt.Errorf("upload %s: %w", SampleVideoKey, err)
}
return nil
}
return fmt.Errorf("%s missing in storage and no readable candidate found among %v", SampleVideoKey, candidates)
}
// DefaultSampleCandidates returns common relative locations for the checked-in sample.
func DefaultSampleCandidates() []string {
cwd, _ := os.Getwd()
return []string{
filepath.Join(cwd, "media", SampleVideoKey),
filepath.Join(cwd, "backend", "media", SampleVideoKey),
filepath.Join("media", SampleVideoKey),
filepath.Join("backend", "media", SampleVideoKey),
}
}
+311
View File
@@ -0,0 +1,311 @@
package storage
import (
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// Storage abstracts media file persistence.
type Storage interface {
Save(key string, file multipart.File, size int64) (string, error)
// Put writes raw bytes (used for seed / sample media).
Put(key string, r io.Reader, size int64) (string, error)
Open(key string) (io.ReadCloser, time.Time, string, error)
// Exists reports whether key is present.
Exists(key string) bool
// Serve streams a key with HTTP Range support when possible.
Serve(w http.ResponseWriter, r *http.Request, key string) error
// URL returns the absolute serving URL for a key (or "" if none).
URL(key string) string
}
// Local implements Storage using the local filesystem, served by the API at /media/.
type Local struct {
dir string
baseURL string
}
func NewLocal(dir, publicURL string) *Local {
return &Local{dir: dir, baseURL: publicURL}
}
// cleanLocalKey keeps nested keys like chats/1-x.png while blocking path traversal.
func cleanLocalKey(key string) (string, error) {
key = strings.TrimSpace(strings.TrimPrefix(key, "/"))
key = filepath.ToSlash(key)
if key == "" || strings.Contains(key, "\\") || strings.Contains(key, "..") {
return "", fmt.Errorf("invalid key")
}
parts := strings.Split(key, "/")
for _, p := range parts {
if p == "" || p == "." || p == ".." || filepath.Base(p) != p {
return "", fmt.Errorf("invalid key")
}
}
return key, nil
}
func (l *Local) Save(key string, file multipart.File, size int64) (string, error) {
return l.Put(key, file, size)
}
func (l *Local) Put(key string, r io.Reader, size int64) (string, error) {
clean, err := cleanLocalKey(key)
if err != nil {
return "", err
}
dst := filepath.Join(l.dir, filepath.FromSlash(clean))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", err
}
out, err := os.Create(dst)
if err != nil {
return "", err
}
defer out.Close()
if size > 0 {
if _, err := io.CopyN(out, r, size); err != nil && err != io.EOF {
return "", err
}
} else {
if _, err := io.Copy(out, r); err != nil {
return "", err
}
}
return l.URL(clean), nil
}
func (l *Local) Exists(key string) bool {
clean, err := cleanLocalKey(key)
if err != nil {
return false
}
_, err = os.Stat(filepath.Join(l.dir, filepath.FromSlash(clean)))
return err == nil
}
func (l *Local) Open(key string) (io.ReadCloser, time.Time, string, error) {
clean, err := cleanLocalKey(key)
if err != nil {
return nil, time.Time{}, "", err
}
f, err := os.Open(filepath.Join(l.dir, filepath.FromSlash(clean)))
if err != nil {
return nil, time.Time{}, "", err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, time.Time{}, "", err
}
return f, info.ModTime(), mimeType(clean), nil
}
func (l *Local) Serve(w http.ResponseWriter, r *http.Request, key string) error {
clean, err := cleanLocalKey(key)
if err != nil {
return err
}
path := filepath.Join(l.dir, filepath.FromSlash(clean))
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
setInlineMediaHeaders(w, mimeType(clean))
// Empty name avoids Content-Disposition attachment hints from some clients.
http.ServeContent(w, r, "", info.ModTime(), f)
return nil
}
func (l *Local) URL(key string) string {
if key == "" {
return ""
}
clean, err := cleanLocalKey(key)
if err != nil {
return ""
}
return l.baseURL + "/media/" + clean
}
func (l *Local) Dir() string { return l.dir }
type S3 struct {
client *s3.Client
bucket string
baseURL string
}
func NewS3(ctx context.Context, endpoint, region, accessKey, secretKey, bucket, publicURL string) (*S3, error) {
cfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(cfg, func(options *s3.Options) {
options.BaseEndpoint = aws.String(endpoint)
options.UsePathStyle = true
})
storage := &S3{client: client, bucket: bucket, baseURL: strings.TrimRight(publicURL, "/")}
if _, err := client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}); err != nil {
if _, createErr := client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}); createErr != nil {
return nil, createErr
}
}
return storage, nil
}
func (s *S3) Save(key string, file multipart.File, size int64) (string, error) {
return s.Put(key, file, size)
}
func (s *S3) Put(key string, r io.Reader, size int64) (string, error) {
objectKey := cleanKey(key)
if objectKey == "" {
return "", fmt.Errorf("invalid key")
}
input := &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(objectKey),
Body: r,
}
if size > 0 {
input.ContentLength = aws.Int64(size)
}
_, err := s.client.PutObject(context.Background(), input)
if err != nil {
return "", err
}
return s.URL(key), nil
}
func (s *S3) Exists(key string) bool {
objectKey := cleanKey(key)
if objectKey == "" {
return false
}
_, err := s.client.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(objectKey),
})
return err == nil
}
func (s *S3) Open(key string) (io.ReadCloser, time.Time, string, error) {
objectKey := cleanKey(key)
if objectKey == "" {
return nil, time.Time{}, "", fmt.Errorf("invalid key")
}
result, err := s.client.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(objectKey),
})
if err != nil {
return nil, time.Time{}, "", err
}
return result.Body, time.Time{}, mimeType(key), nil
}
func (s *S3) Serve(w http.ResponseWriter, r *http.Request, key string) error {
objectKey := cleanKey(key)
if objectKey == "" {
return fmt.Errorf("invalid key")
}
input := &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(objectKey),
}
if rng := r.Header.Get("Range"); rng != "" {
input.Range = aws.String(rng)
}
result, err := s.client.GetObject(r.Context(), input)
if err != nil {
return err
}
defer result.Body.Close()
ct := mimeType(key)
if result.ContentType != nil && *result.ContentType != "" {
ct = *result.ContentType
}
w.Header().Set("Content-Type", ct)
setInlineMediaHeaders(w, ct)
w.Header().Set("Accept-Ranges", "bytes")
if result.ContentLength != nil {
w.Header().Set("Content-Length", fmt.Sprintf("%d", *result.ContentLength))
}
if result.ContentRange != nil {
w.Header().Set("Content-Range", *result.ContentRange)
w.WriteHeader(http.StatusPartialContent)
} else {
w.WriteHeader(http.StatusOK)
}
_, err = io.Copy(w, result.Body)
return err
}
func (s *S3) URL(key string) string {
return s.baseURL + "/media/" + cleanKey(key)
}
func cleanKey(key string) string {
clean := filepath.ToSlash(filepath.Clean(key))
clean = strings.TrimPrefix(clean, "/")
if clean == "" || clean == "." || strings.Contains(clean, "..") {
return ""
}
return clean
}
func mimeType(key string) string {
switch strings.ToLower(filepath.Ext(key)) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".mp3":
return "audio/mpeg"
case ".pdf":
return "application/pdf"
default:
return "application/octet-stream"
}
}
// setInlineMediaHeaders discourages "Save as" download UX while keeping streaming/Range intact.
func setInlineMediaHeaders(w http.ResponseWriter, contentType string) {
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
}