chore: add main directories
Some checks failed
CI/CD Pipeline / Test Suite (push) Has been cancelled
CI/CD Pipeline / Security Audit (push) Has been cancelled
CI/CD Pipeline / Build Docker Image (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Performance Benchmarks (push) Has been cancelled
CI/CD Pipeline / Cleanup (push) Has been cancelled

This commit is contained in:
Jesús Pérex 2025-07-07 23:10:30 +01:00
parent 76d374ea18
commit 31ab424d9d
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
21 changed files with 4356 additions and 0 deletions

View File

@ -0,0 +1,504 @@
-- Migration 001: Initial Database Setup
-- This migration creates all necessary tables for authentication and content management
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table - core user information
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255),
display_name VARCHAR(100),
avatar_url TEXT,
is_active BOOLEAN DEFAULT TRUE,
email_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_login TIMESTAMPTZ,
-- Profile information
first_name VARCHAR(100),
last_name VARCHAR(100),
bio TEXT,
timezone VARCHAR(50),
locale VARCHAR(10),
preferences JSONB DEFAULT '{}'::jsonb,
-- Constraints
CONSTRAINT users_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
CONSTRAINT users_username_format CHECK (username ~* '^[A-Za-z0-9_-]{3,50}$'),
CONSTRAINT users_display_name_length CHECK (char_length(display_name) >= 1)
);
-- User roles table - RBAC implementation
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, role),
-- Constraints
CONSTRAINT user_roles_valid_role CHECK (role IN ('admin', 'moderator', 'user', 'guest') OR role LIKE 'custom_%')
);
-- OAuth accounts table - external authentication providers
CREATE TABLE oauth_accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
provider_email VARCHAR(255) NOT NULL,
provider_data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
UNIQUE(provider, provider_id),
CONSTRAINT oauth_accounts_valid_provider CHECK (provider IN ('google', 'github', 'discord', 'microsoft') OR provider LIKE 'custom_%')
);
-- Sessions table - session management
CREATE TABLE sessions (
id VARCHAR(255) PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_accessed TIMESTAMPTZ DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
is_active BOOLEAN DEFAULT TRUE,
-- Constraints
CONSTRAINT sessions_valid_expiry CHECK (expires_at > created_at)
);
-- Tokens table - password reset, email verification, etc.
CREATE TABLE tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
token_type VARCHAR(50) NOT NULL,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
used_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
-- Constraints
CONSTRAINT tokens_valid_type CHECK (token_type IN ('password_reset', 'email_verification', 'account_activation')),
CONSTRAINT tokens_valid_expiry CHECK (expires_at > created_at),
CONSTRAINT tokens_used_logic CHECK (used_at IS NULL OR used_at >= created_at)
);
-- Permissions table - for fine-grained access control
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
resource VARCHAR(100) NOT NULL,
action VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT permissions_name_format CHECK (name ~* '^[a-z][a-z0-9_]*[a-z0-9]$'),
CONSTRAINT permissions_resource_format CHECK (resource ~* '^[a-z][a-z0-9_]*[a-z0-9]$'),
CONSTRAINT permissions_action_format CHECK (action IN ('create', 'read', 'update', 'delete', 'manage', 'execute'))
);
-- Role permissions table - many-to-many relationship
CREATE TABLE role_permissions (
role VARCHAR(50) NOT NULL,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (role, permission_id),
-- Constraints
CONSTRAINT role_permissions_valid_role CHECK (role IN ('admin', 'moderator', 'user', 'guest') OR role LIKE 'custom_%')
);
-- User audit log - track important user actions
CREATE TABLE user_audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource VARCHAR(100),
resource_id UUID,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT audit_log_valid_action CHECK (action IN ('login', 'logout', 'register', 'update_profile', 'change_password', 'oauth_login', 'password_reset', 'email_verify'))
);
-- Page contents table - main content management
CREATE TABLE page_contents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug VARCHAR(255) NOT NULL UNIQUE,
title VARCHAR(500) NOT NULL,
name VARCHAR(255) NOT NULL,
author VARCHAR(255),
author_id UUID,
content_type VARCHAR(50) NOT NULL DEFAULT 'page',
content_format VARCHAR(20) NOT NULL DEFAULT 'markdown',
content TEXT NOT NULL,
container VARCHAR(255) NOT NULL DEFAULT 'page-container',
state VARCHAR(20) NOT NULL DEFAULT 'draft',
require_login BOOLEAN NOT NULL DEFAULT FALSE,
date_init TIMESTAMPTZ NOT NULL DEFAULT NOW(),
date_end TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
category VARCHAR(255),
featured_image VARCHAR(500),
excerpt TEXT,
seo_title VARCHAR(500),
seo_description TEXT,
allow_comments BOOLEAN NOT NULL DEFAULT TRUE,
view_count BIGINT NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
);
-- Indexes for users table
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_is_active ON users(is_active);
CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_users_email_verified ON users(email_verified);
-- Indexes for user_roles table
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_user_roles_role ON user_roles(role);
-- Indexes for oauth_accounts table
CREATE INDEX idx_oauth_accounts_user_id ON oauth_accounts(user_id);
CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider, provider_id);
CREATE INDEX idx_oauth_accounts_provider_email ON oauth_accounts(provider_email);
-- Indexes for sessions table
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX idx_sessions_is_active ON sessions(is_active);
CREATE INDEX idx_sessions_last_accessed ON sessions(last_accessed);
-- Indexes for tokens table
CREATE INDEX idx_tokens_user_id ON tokens(user_id);
CREATE INDEX idx_tokens_type ON tokens(token_type);
CREATE INDEX idx_tokens_expires_at ON tokens(expires_at);
CREATE INDEX idx_tokens_is_active ON tokens(is_active);
CREATE INDEX idx_tokens_hash ON tokens(token_hash);
-- Indexes for permissions table
CREATE INDEX idx_permissions_name ON permissions(name);
CREATE INDEX idx_permissions_resource ON permissions(resource);
CREATE INDEX idx_permissions_action ON permissions(action);
-- Indexes for role_permissions table
CREATE INDEX idx_role_permissions_role ON role_permissions(role);
CREATE INDEX idx_role_permissions_permission ON role_permissions(permission_id);
-- Indexes for user_audit_log table
CREATE INDEX idx_user_audit_log_user_id ON user_audit_log(user_id);
CREATE INDEX idx_user_audit_log_action ON user_audit_log(action);
CREATE INDEX idx_user_audit_log_created_at ON user_audit_log(created_at);
-- Indexes for page_contents table
CREATE INDEX idx_page_contents_slug ON page_contents(slug);
CREATE INDEX idx_page_contents_state ON page_contents(state);
CREATE INDEX idx_page_contents_content_type ON page_contents(content_type);
CREATE INDEX idx_page_contents_author_id ON page_contents(author_id);
CREATE INDEX idx_page_contents_category ON page_contents(category);
CREATE INDEX idx_page_contents_tags ON page_contents USING GIN(tags);
CREATE INDEX idx_page_contents_published_at ON page_contents(published_at);
CREATE INDEX idx_page_contents_created_at ON page_contents(created_at);
CREATE INDEX idx_page_contents_view_count ON page_contents(view_count);
CREATE INDEX idx_page_contents_sort_order ON page_contents(sort_order);
CREATE INDEX idx_page_contents_metadata ON page_contents USING GIN(metadata);
-- Full-text search index for page_contents
CREATE INDEX idx_page_contents_search ON page_contents USING GIN(
to_tsvector('english', title || ' ' || COALESCE(content, '') || ' ' || COALESCE(excerpt, ''))
);
-- Partial indexes for published content
CREATE INDEX idx_page_contents_published_public ON page_contents(created_at DESC)
WHERE state = 'published' AND require_login = FALSE;
CREATE INDEX idx_page_contents_published_by_type ON page_contents(content_type, created_at DESC)
WHERE state = 'published';
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Triggers to automatically update updated_at
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_oauth_accounts_updated_at
BEFORE UPDATE ON oauth_accounts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_page_contents_updated_at
BEFORE UPDATE ON page_contents
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Function to automatically assign default role to new users
CREATE OR REPLACE FUNCTION assign_default_role()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO user_roles (user_id, role) VALUES (NEW.id, 'user');
RETURN NEW;
END;
$$ language 'plpgsql';
-- Trigger to assign default role
CREATE TRIGGER assign_default_role_trigger
AFTER INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION assign_default_role();
-- Function to log user actions
CREATE OR REPLACE FUNCTION log_user_action(
p_user_id UUID,
p_action VARCHAR(100),
p_resource VARCHAR(100) DEFAULT NULL,
p_resource_id UUID DEFAULT NULL,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL,
p_ip_address INET DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
log_id UUID;
BEGIN
INSERT INTO user_audit_log (
user_id, action, resource, resource_id,
old_values, new_values, ip_address, user_agent
) VALUES (
p_user_id, p_action, p_resource, p_resource_id,
p_old_values, p_new_values, p_ip_address, p_user_agent
) RETURNING id INTO log_id;
RETURN log_id;
END;
$$ LANGUAGE plpgsql;
-- Function to clean up expired sessions and tokens
CREATE OR REPLACE FUNCTION cleanup_expired_auth_data()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER := 0;
temp_count INTEGER;
BEGIN
-- Delete expired sessions
DELETE FROM sessions WHERE expires_at < NOW();
GET DIAGNOSTICS temp_count = ROW_COUNT;
deleted_count := deleted_count + temp_count;
-- Delete expired tokens
DELETE FROM tokens WHERE expires_at < NOW();
GET DIAGNOSTICS temp_count = ROW_COUNT;
deleted_count := deleted_count + temp_count;
-- Delete old audit logs (older than 1 year)
DELETE FROM user_audit_log WHERE created_at < NOW() - INTERVAL '1 year';
GET DIAGNOSTICS temp_count = ROW_COUNT;
deleted_count := deleted_count + temp_count;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Default permissions
INSERT INTO permissions (name, description, resource, action) VALUES
('read_users', 'Read user information', 'users', 'read'),
('write_users', 'Create and update users', 'users', 'create'),
('delete_users', 'Delete users', 'users', 'delete'),
('manage_users', 'Full user management', 'users', 'manage'),
('read_content', 'Read content', 'content', 'read'),
('write_content', 'Create and update content', 'content', 'create'),
('delete_content', 'Delete content', 'content', 'delete'),
('manage_content', 'Full content management', 'content', 'manage'),
('manage_roles', 'Manage user roles and permissions', 'roles', 'manage'),
('manage_system', 'System administration', 'system', 'manage'),
('read_audit_log', 'Read audit logs', 'audit_log', 'read'),
('execute_maintenance', 'Execute maintenance tasks', 'system', 'execute');
-- Default role permissions
INSERT INTO role_permissions (role, permission_id)
SELECT 'admin', id FROM permissions;
INSERT INTO role_permissions (role, permission_id)
SELECT 'moderator', id FROM permissions
WHERE name IN ('read_users', 'read_content', 'write_content', 'delete_content', 'read_audit_log');
INSERT INTO role_permissions (role, permission_id)
SELECT 'user', id FROM permissions
WHERE name IN ('read_content', 'write_content');
INSERT INTO role_permissions (role, permission_id)
SELECT 'guest', id FROM permissions
WHERE name IN ('read_content');
-- Create a default admin user (password: 'admin123' - change this!)
-- Password hash for 'admin123' with Argon2
INSERT INTO users (email, username, password_hash, display_name, email_verified, is_active)
VALUES (
'admin@example.com',
'admin',
'$argon2id$v=19$m=19456,t=2,p=1$4K5FCBeajDVi8smeWgce3w$y9zZkuvLE3H3GwTFgfl/ngjqlnjiuDRIPiBqu0yFICA',
'System Administrator',
TRUE,
TRUE
);
-- Assign admin role to the default admin user
INSERT INTO user_roles (user_id, role)
SELECT id, 'admin' FROM users WHERE username = 'admin';
-- Sample page content data
INSERT INTO page_contents (
slug, title, name, content_type, content, container, state,
require_login, tags, category, excerpt, allow_comments
) VALUES
(
'welcome',
'Welcome to Our Site',
'welcome-page',
'page',
'# Welcome to Our Site
Welcome to our amazing website! This is a sample page to demonstrate our content management system.
## Features
- Dynamic content loading
- Markdown support
- SEO optimization
- Multi-format content support
Feel free to explore and discover what we have to offer.',
'page-container',
'published',
false,
ARRAY['welcome', 'introduction'],
'general',
'Welcome to our amazing website! This is a sample page to demonstrate our content management system.',
false
),
(
'about',
'About Us',
'about-page',
'page',
'# About Us
We are a team of passionate developers creating amazing web experiences.
## Our Mission
To build innovative solutions that make the web a better place.
## Our Values
- Quality
- Innovation
- User Experience
- Open Source',
'page-container',
'published',
false,
ARRAY['about', 'company'],
'general',
'We are a team of passionate developers creating amazing web experiences.',
false
),
(
'sample-blog-post',
'Getting Started with Rust and Web Development',
'sample-blog',
'blog',
'# Getting Started with Rust and Web Development
Rust is becoming increasingly popular for web development, and for good reason!
## Why Rust for Web Development?
1. **Performance**: Rust offers near C++ performance
2. **Safety**: Memory safety without garbage collection
3. **Concurrency**: Excellent support for concurrent programming
4. **Ecosystem**: Growing ecosystem of web frameworks
## Popular Rust Web Frameworks
- **Axum**: A modern, async web framework
- **Warp**: A super-easy, composable, web server framework
- **Actix-web**: A powerful, pragmatic, and extremely fast web framework
## Getting Started
```rust
use axum::{response::Html, routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { Html("<h1>Hello, World!</h1>") }));
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
```
Happy coding!',
'blog-container',
'published',
false,
ARRAY['rust', 'web-development', 'programming', 'tutorial'],
'technology',
'Learn how to get started with Rust for web development. Discover popular frameworks and see practical examples.',
true
);
-- Comments on tables
COMMENT ON TABLE users IS 'Core user accounts and profile information';
COMMENT ON TABLE user_roles IS 'User role assignments for RBAC';
COMMENT ON TABLE oauth_accounts IS 'External OAuth provider account links';
COMMENT ON TABLE sessions IS 'User session management';
COMMENT ON TABLE tokens IS 'Security tokens for password reset, email verification, etc.';
COMMENT ON TABLE permissions IS 'System permissions for fine-grained access control';
COMMENT ON TABLE role_permissions IS 'Role to permission mappings';
COMMENT ON TABLE user_audit_log IS 'Audit trail for user actions';
COMMENT ON TABLE page_contents IS 'Main content management table for pages, posts, and other content';
-- Comments on important columns
COMMENT ON COLUMN users.password_hash IS 'Argon2 hashed password, NULL for OAuth-only accounts';
COMMENT ON COLUMN users.preferences IS 'JSON object for user preferences and settings';
COMMENT ON COLUMN oauth_accounts.provider_data IS 'Raw user data from OAuth provider';
COMMENT ON COLUMN sessions.id IS 'Session identifier, should be cryptographically secure';
COMMENT ON COLUMN tokens.token_hash IS 'Hashed token value for security';
COMMENT ON COLUMN user_audit_log.old_values IS 'Previous values before change (for updates)';
COMMENT ON COLUMN user_audit_log.new_values IS 'New values after change (for updates)';
COMMENT ON COLUMN page_contents.metadata IS 'JSON object for additional content metadata';
COMMENT ON COLUMN page_contents.tags IS 'Array of tags for content categorization';
COMMENT ON COLUMN page_contents.state IS 'Content state: draft, published, archived';

View File

@ -0,0 +1,100 @@
-- Initial database setup for PostgreSQL
-- Migration: 001_initial_setup
-- Database: PostgreSQL
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
is_verified BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User sessions table
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Content table for CMS functionality
CREATE TABLE IF NOT EXISTS content (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content_type VARCHAR(50) NOT NULL DEFAULT 'markdown',
body TEXT,
metadata JSONB,
is_published BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User roles table
CREATE TABLE IF NOT EXISTS user_roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
permissions JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User role assignments
CREATE TABLE IF NOT EXISTS user_role_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
UNIQUE(user_id, role_id)
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_content_slug ON content(slug);
CREATE INDEX IF NOT EXISTS idx_content_published ON content(is_published);
CREATE INDEX IF NOT EXISTS idx_content_created_by ON content(created_by);
CREATE INDEX IF NOT EXISTS idx_content_type ON content(content_type);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_user ON user_role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_role ON user_role_assignments(role_id);
-- Update timestamp trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply triggers to tables with updated_at columns
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_content_updated_at
BEFORE UPDATE ON content
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Insert default roles
INSERT INTO user_roles (name, description, permissions) VALUES
('admin', 'Administrator with full access', '{"all": true}'),
('editor', 'Content editor', '{"content": {"read": true, "write": true, "delete": true}}'),
('user', 'Regular user', '{"content": {"read": true}}')
ON CONFLICT (name) DO NOTHING;

View File

@ -0,0 +1,96 @@
-- Initial database setup for SQLite
-- Migration: 001_initial_setup
-- Database: SQLite
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_verified INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- User sessions table
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Content table for CMS functionality
CREATE TABLE IF NOT EXISTS content (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content_type TEXT NOT NULL DEFAULT 'markdown',
body TEXT,
metadata TEXT, -- JSON as TEXT in SQLite
is_published INTEGER NOT NULL DEFAULT 0,
published_at DATETIME,
created_by TEXT REFERENCES users(id),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- User roles table
CREATE TABLE IF NOT EXISTS user_roles (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL UNIQUE,
description TEXT,
permissions TEXT, -- JSON as TEXT in SQLite
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- User role assignments
CREATE TABLE IF NOT EXISTS user_role_assignments (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE,
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
assigned_by TEXT REFERENCES users(id),
UNIQUE(user_id, role_id)
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_content_slug ON content(slug);
CREATE INDEX IF NOT EXISTS idx_content_published ON content(is_published);
CREATE INDEX IF NOT EXISTS idx_content_created_by ON content(created_by);
CREATE INDEX IF NOT EXISTS idx_content_type ON content(content_type);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_user ON user_role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_role ON user_role_assignments(role_id);
-- Triggers for updated_at timestamps (SQLite doesn't have automatic timestamp updates)
CREATE TRIGGER IF NOT EXISTS update_users_updated_at
AFTER UPDATE ON users
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TRIGGER IF NOT EXISTS update_content_updated_at
AFTER UPDATE ON content
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE content SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- Insert default roles
INSERT INTO user_roles (name, description, permissions) VALUES
('admin', 'Administrator with full access', '{"all": true}'),
('editor', 'Content editor', '{"content": {"read": true, "write": true, "delete": true}}'),
('user', 'Regular user', '{"content": {"read": true}}')
ON CONFLICT (name) DO NOTHING;

View File

@ -0,0 +1,101 @@
-- Migration 002: Add Two-Factor Authentication Support
-- This migration adds TOTP (Time-based One-Time Password) support for 2FA
-- User 2FA settings table
CREATE TABLE user_2fa (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
secret VARCHAR(32) NOT NULL, -- Base32 encoded TOTP secret
is_enabled BOOLEAN DEFAULT FALSE,
backup_codes TEXT[], -- Array of backup codes
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_used TIMESTAMPTZ,
-- Constraints
CONSTRAINT user_2fa_secret_length CHECK (char_length(secret) = 32),
CONSTRAINT user_2fa_backup_codes_count CHECK (array_length(backup_codes, 1) <= 10)
);
-- 2FA recovery codes table (for better tracking)
CREATE TABLE user_2fa_recovery_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL, -- Hashed recovery code
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT user_2fa_recovery_codes_unique UNIQUE(user_id, code_hash)
);
-- 2FA authentication attempts table (for rate limiting and security)
CREATE TABLE user_2fa_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
ip_address INET,
user_agent TEXT,
success BOOLEAN NOT NULL,
code_type VARCHAR(20) NOT NULL, -- 'totp' or 'backup'
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT user_2fa_attempts_valid_code_type CHECK (code_type IN ('totp', 'backup'))
);
-- Add 2FA required flag to users table
ALTER TABLE users ADD COLUMN two_factor_required BOOLEAN DEFAULT FALSE;
-- Add 2FA verified flag to sessions table
ALTER TABLE sessions ADD COLUMN two_factor_verified BOOLEAN DEFAULT FALSE;
-- Update tokens table to support 2FA setup tokens
ALTER TABLE tokens DROP CONSTRAINT tokens_valid_type;
ALTER TABLE tokens ADD CONSTRAINT tokens_valid_type CHECK (
token_type IN ('password_reset', 'email_verification', 'account_activation', '2fa_setup')
);
-- Indexes for performance
CREATE INDEX idx_user_2fa_user_id ON user_2fa(user_id);
CREATE INDEX idx_user_2fa_recovery_codes_user_id ON user_2fa_recovery_codes(user_id);
CREATE INDEX idx_user_2fa_attempts_user_id ON user_2fa_attempts(user_id);
CREATE INDEX idx_user_2fa_attempts_created_at ON user_2fa_attempts(created_at);
CREATE INDEX idx_sessions_2fa_verified ON sessions(two_factor_verified);
-- Function to cleanup old 2FA attempts (for maintenance)
CREATE OR REPLACE FUNCTION cleanup_old_2fa_attempts()
RETURNS void AS $$
BEGIN
DELETE FROM user_2fa_attempts
WHERE created_at < NOW() - INTERVAL '7 days';
END;
$$ LANGUAGE plpgsql;
-- Trigger to update updated_at on user_2fa table
CREATE OR REPLACE FUNCTION update_user_2fa_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_user_2fa_updated_at
BEFORE UPDATE ON user_2fa
FOR EACH ROW
EXECUTE FUNCTION update_user_2fa_updated_at();
-- Comments for documentation
COMMENT ON TABLE user_2fa IS 'Stores TOTP secrets and 2FA configuration for users';
COMMENT ON COLUMN user_2fa.secret IS 'Base32 encoded TOTP secret key';
COMMENT ON COLUMN user_2fa.backup_codes IS 'Array of hashed backup codes for account recovery';
COMMENT ON COLUMN user_2fa.is_enabled IS 'Whether 2FA is currently enabled for the user';
COMMENT ON TABLE user_2fa_recovery_codes IS 'Individual recovery codes for better tracking and management';
COMMENT ON COLUMN user_2fa_recovery_codes.code_hash IS 'SHA256 hash of the recovery code';
COMMENT ON TABLE user_2fa_attempts IS 'Tracks 2FA authentication attempts for security monitoring';
COMMENT ON COLUMN user_2fa_attempts.code_type IS 'Type of 2FA code used: totp or backup';
COMMENT ON COLUMN users.two_factor_required IS 'Whether 2FA is required for this user account';
COMMENT ON COLUMN sessions.two_factor_verified IS 'Whether this session has completed 2FA verification';

View File

@ -0,0 +1,131 @@
-- Add 2FA support to users table
-- Migration: 002_add_2fa_support
-- Database: PostgreSQL
-- Add 2FA columns to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS two_factor_secret VARCHAR(255),
ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS backup_codes TEXT[], -- Array of backup codes
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;
-- Create 2FA recovery codes table
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create login attempts table for security tracking
CREATE TABLE IF NOT EXISTS login_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
ip_address INET,
user_agent TEXT,
success BOOLEAN NOT NULL,
failure_reason VARCHAR(255),
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create password reset tokens table
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create email verification tokens table
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL, -- Allow email change verification
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create indexes for 2FA and security tables
CREATE INDEX IF NOT EXISTS idx_users_2fa_enabled ON users(two_factor_enabled);
CREATE INDEX IF NOT EXISTS idx_users_locked_until ON users(locked_until);
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login_at);
CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_user_id ON two_factor_recovery_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_hash ON two_factor_recovery_codes(code_hash);
CREATE INDEX IF NOT EXISTS idx_login_attempts_user_id ON login_attempts(user_id);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip_address);
CREATE INDEX IF NOT EXISTS idx_login_attempts_time ON login_attempts(attempted_at);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_hash ON email_verification_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id);
-- Add constraint to ensure backup codes are valid JSON array
ALTER TABLE users ADD CONSTRAINT backup_codes_valid_json
CHECK (backup_codes IS NULL OR jsonb_typeof(backup_codes::jsonb) = 'array');
-- Function to clean up expired tokens
CREATE OR REPLACE FUNCTION cleanup_expired_tokens()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER := 0;
BEGIN
-- Clean up expired password reset tokens
DELETE FROM password_reset_tokens
WHERE expires_at < NOW() AND used_at IS NULL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
-- Clean up expired email verification tokens
DELETE FROM email_verification_tokens
WHERE expires_at < NOW() AND verified_at IS NULL;
GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT;
-- Clean up old login attempts (keep last 30 days)
DELETE FROM login_attempts
WHERE attempted_at < NOW() - INTERVAL '30 days';
GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT;
-- Clean up expired user sessions
DELETE FROM user_sessions
WHERE expires_at < NOW();
GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to unlock user accounts after lock period
CREATE OR REPLACE FUNCTION unlock_expired_accounts()
RETURNS INTEGER AS $$
DECLARE
unlocked_count INTEGER := 0;
BEGIN
UPDATE users
SET locked_until = NULL,
failed_login_attempts = 0
WHERE locked_until IS NOT NULL
AND locked_until < NOW();
GET DIAGNOSTICS unlocked_count = ROW_COUNT;
RETURN unlocked_count;
END;
$$ LANGUAGE plpgsql;
-- Add comments for documentation
COMMENT ON COLUMN users.two_factor_secret IS 'Base32-encoded TOTP secret for 2FA';
COMMENT ON COLUMN users.two_factor_enabled IS 'Whether 2FA is enabled for this user';
COMMENT ON COLUMN users.backup_codes IS 'JSON array of hashed backup codes for 2FA recovery';
COMMENT ON COLUMN users.last_login_at IS 'Timestamp of last successful login';
COMMENT ON COLUMN users.failed_login_attempts IS 'Number of consecutive failed login attempts';
COMMENT ON COLUMN users.locked_until IS 'Account locked until this timestamp due to failed attempts';
COMMENT ON TABLE two_factor_recovery_codes IS 'Individual 2FA recovery codes for account recovery';
COMMENT ON TABLE login_attempts IS 'Log of all login attempts for security monitoring';
COMMENT ON TABLE password_reset_tokens IS 'Tokens for password reset functionality';
COMMENT ON TABLE email_verification_tokens IS 'Tokens for email verification and changes';

View File

@ -0,0 +1,117 @@
-- Add 2FA support to users table
-- Migration: 002_add_2fa_support
-- Database: SQLite
-- Add 2FA columns to users table (SQLite requires one column at a time)
ALTER TABLE users ADD COLUMN two_factor_secret TEXT;
ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN backup_codes TEXT; -- JSON array as TEXT
ALTER TABLE users ADD COLUMN last_login_at DATETIME;
ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN locked_until DATETIME;
-- Create 2FA recovery codes table
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create login attempts table for security tracking
CREATE TABLE IF NOT EXISTS login_attempts (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
failure_reason TEXT,
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create password reset tokens table
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create email verification tokens table
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
email TEXT NOT NULL, -- Allow email change verification
expires_at DATETIME NOT NULL,
verified_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for 2FA and security tables
CREATE INDEX IF NOT EXISTS idx_users_2fa_enabled ON users(two_factor_enabled);
CREATE INDEX IF NOT EXISTS idx_users_locked_until ON users(locked_until);
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login_at);
CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_user_id ON two_factor_recovery_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_hash ON two_factor_recovery_codes(code_hash);
CREATE INDEX IF NOT EXISTS idx_login_attempts_user_id ON login_attempts(user_id);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip_address);
CREATE INDEX IF NOT EXISTS idx_login_attempts_time ON login_attempts(attempted_at);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_hash ON email_verification_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id);
-- SQLite doesn't support stored procedures, but we can create triggers for cleanup
-- Trigger to automatically clean up expired password reset tokens on insert
CREATE TRIGGER IF NOT EXISTS cleanup_expired_password_tokens
AFTER INSERT ON password_reset_tokens
BEGIN
DELETE FROM password_reset_tokens
WHERE expires_at < datetime('now') AND used_at IS NULL;
END;
-- Trigger to automatically clean up expired email verification tokens on insert
CREATE TRIGGER IF NOT EXISTS cleanup_expired_email_tokens
AFTER INSERT ON email_verification_tokens
BEGIN
DELETE FROM email_verification_tokens
WHERE expires_at < datetime('now') AND verified_at IS NULL;
END;
-- Trigger to clean up old login attempts (keep last 1000 entries per user)
CREATE TRIGGER IF NOT EXISTS cleanup_old_login_attempts
AFTER INSERT ON login_attempts
BEGIN
DELETE FROM login_attempts
WHERE id IN (
SELECT id FROM login_attempts
WHERE user_id = NEW.user_id
ORDER BY attempted_at DESC
LIMIT -1 OFFSET 1000
);
END;
-- Trigger to clean up expired user sessions on new session creation
CREATE TRIGGER IF NOT EXISTS cleanup_expired_sessions
AFTER INSERT ON user_sessions
BEGIN
DELETE FROM user_sessions
WHERE expires_at < datetime('now');
END;
-- Trigger to automatically unlock accounts when lock period expires
CREATE TRIGGER IF NOT EXISTS auto_unlock_accounts
BEFORE UPDATE ON users
FOR EACH ROW
WHEN NEW.locked_until IS NOT NULL
AND NEW.locked_until < datetime('now')
BEGIN
UPDATE users
SET locked_until = NULL,
failed_login_attempts = 0
WHERE id = NEW.id;
END;

View File

@ -0,0 +1,320 @@
-- RBAC System Migration for PostgreSQL
-- Migration: 003_rbac_system
-- Database: PostgreSQL
-- User categories table
CREATE TABLE IF NOT EXISTS user_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
parent_id UUID REFERENCES user_categories(id) ON DELETE CASCADE,
metadata JSONB,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User tags table
CREATE TABLE IF NOT EXISTS user_tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
color VARCHAR(7), -- hex color code
metadata JSONB,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User category assignments
CREATE TABLE IF NOT EXISTS user_category_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES user_categories(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
expires_at TIMESTAMPTZ,
UNIQUE(user_id, category_id)
);
-- User tag assignments
CREATE TABLE IF NOT EXISTS user_tag_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES user_tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
expires_at TIMESTAMPTZ,
UNIQUE(user_id, tag_id)
);
-- Access rules table
CREATE TABLE IF NOT EXISTS access_rules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
resource_type VARCHAR(50) NOT NULL, -- 'database', 'file', 'directory', 'content', 'api'
resource_name VARCHAR(500) NOT NULL, -- supports wildcards
action VARCHAR(50) NOT NULL, -- 'read', 'write', 'delete', 'execute'
priority INTEGER NOT NULL DEFAULT 0, -- higher priority rules evaluated first
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Role requirements for access rules
CREATE TABLE IF NOT EXISTS access_rule_roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE,
UNIQUE(rule_id, role_id)
);
-- Permission requirements for access rules
CREATE TABLE IF NOT EXISTS access_rule_permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE,
permission_name VARCHAR(100) NOT NULL,
resource_scope VARCHAR(255), -- optional scope for permission
UNIQUE(rule_id, permission_name, resource_scope)
);
-- Category requirements for access rules
CREATE TABLE IF NOT EXISTS access_rule_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES user_categories(id) ON DELETE CASCADE,
requirement_type VARCHAR(20) NOT NULL DEFAULT 'required', -- 'required', 'denied'
UNIQUE(rule_id, category_id, requirement_type)
);
-- Tag requirements for access rules
CREATE TABLE IF NOT EXISTS access_rule_tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES user_tags(id) ON DELETE CASCADE,
requirement_type VARCHAR(20) NOT NULL DEFAULT 'required', -- 'required', 'denied'
UNIQUE(rule_id, tag_id, requirement_type)
);
-- Permission cache for performance
CREATE TABLE IF NOT EXISTS user_permission_cache (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
resource_type VARCHAR(50) NOT NULL,
resource_name VARCHAR(500) NOT NULL,
action VARCHAR(50) NOT NULL,
access_result VARCHAR(20) NOT NULL, -- 'allow', 'deny', 'require_additional_auth'
cache_key VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, cache_key)
);
-- RBAC configuration table for storing TOML configs
CREATE TABLE IF NOT EXISTS rbac_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
config_data JSONB NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Audit log for access attempts
CREATE TABLE IF NOT EXISTS access_audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
resource_type VARCHAR(50) NOT NULL,
resource_name VARCHAR(500) NOT NULL,
action VARCHAR(50) NOT NULL,
access_result VARCHAR(20) NOT NULL,
rule_id UUID REFERENCES access_rules(id) ON DELETE SET NULL,
ip_address INET,
user_agent TEXT,
session_id VARCHAR(255),
additional_context JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_user_categories_name ON user_categories(name);
CREATE INDEX IF NOT EXISTS idx_user_categories_parent ON user_categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_user_categories_active ON user_categories(is_active);
CREATE INDEX IF NOT EXISTS idx_user_tags_name ON user_tags(name);
CREATE INDEX IF NOT EXISTS idx_user_tags_active ON user_tags(is_active);
CREATE INDEX IF NOT EXISTS idx_user_category_assignments_user ON user_category_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_category_assignments_category ON user_category_assignments(category_id);
CREATE INDEX IF NOT EXISTS idx_user_category_assignments_expires ON user_category_assignments(expires_at);
CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_user ON user_tag_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_tag ON user_tag_assignments(tag_id);
CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_expires ON user_tag_assignments(expires_at);
CREATE INDEX IF NOT EXISTS idx_access_rules_resource ON access_rules(resource_type, resource_name);
CREATE INDEX IF NOT EXISTS idx_access_rules_action ON access_rules(action);
CREATE INDEX IF NOT EXISTS idx_access_rules_priority ON access_rules(priority DESC);
CREATE INDEX IF NOT EXISTS idx_access_rules_active ON access_rules(is_active);
CREATE INDEX IF NOT EXISTS idx_access_rule_roles_rule ON access_rule_roles(rule_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_roles_role ON access_rule_roles(role_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_permissions_rule ON access_rule_permissions(rule_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_permissions_name ON access_rule_permissions(permission_name);
CREATE INDEX IF NOT EXISTS idx_access_rule_categories_rule ON access_rule_categories(rule_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_categories_category ON access_rule_categories(category_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_tags_rule ON access_rule_tags(rule_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_tags_tag ON access_rule_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_user_permission_cache_user ON user_permission_cache(user_id);
CREATE INDEX IF NOT EXISTS idx_user_permission_cache_key ON user_permission_cache(cache_key);
CREATE INDEX IF NOT EXISTS idx_user_permission_cache_expires ON user_permission_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_rbac_configs_name ON rbac_configs(name);
CREATE INDEX IF NOT EXISTS idx_rbac_configs_active ON rbac_configs(is_active);
CREATE INDEX IF NOT EXISTS idx_access_audit_log_user ON access_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_access_audit_log_resource ON access_audit_log(resource_type, resource_name);
CREATE INDEX IF NOT EXISTS idx_access_audit_log_created ON access_audit_log(created_at);
-- Add triggers for updated_at columns
CREATE TRIGGER update_user_categories_updated_at
BEFORE UPDATE ON user_categories
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_user_tags_updated_at
BEFORE UPDATE ON user_tags
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_access_rules_updated_at
BEFORE UPDATE ON access_rules
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_rbac_configs_updated_at
BEFORE UPDATE ON rbac_configs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Function to clean up expired permission cache
CREATE OR REPLACE FUNCTION cleanup_expired_permission_cache()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM user_permission_cache WHERE expires_at < NOW();
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to get user categories (including inherited)
CREATE OR REPLACE FUNCTION get_user_categories(user_uuid UUID)
RETURNS TABLE(category_name VARCHAR(100)) AS $$
BEGIN
RETURN QUERY
WITH RECURSIVE category_tree AS (
-- Direct categories
SELECT uc.name, uc.parent_id
FROM user_categories uc
JOIN user_category_assignments uca ON uc.id = uca.category_id
WHERE uca.user_id = user_uuid
AND uc.is_active = true
AND (uca.expires_at IS NULL OR uca.expires_at > NOW())
UNION ALL
-- Parent categories
SELECT uc.name, uc.parent_id
FROM user_categories uc
JOIN category_tree ct ON uc.id = ct.parent_id
WHERE uc.is_active = true
)
SELECT DISTINCT ct.name
FROM category_tree ct;
END;
$$ LANGUAGE plpgsql;
-- Function to get user tags
CREATE OR REPLACE FUNCTION get_user_tags(user_uuid UUID)
RETURNS TABLE(tag_name VARCHAR(100)) AS $$
BEGIN
RETURN QUERY
SELECT ut.name
FROM user_tags ut
JOIN user_tag_assignments uta ON ut.id = uta.tag_id
WHERE uta.user_id = user_uuid
AND ut.is_active = true
AND (uta.expires_at IS NULL OR uta.expires_at > NOW());
END;
$$ LANGUAGE plpgsql;
-- Insert default categories
INSERT INTO user_categories (name, description) VALUES
('admin', 'Administrative access category'),
('editor', 'Content editing access category'),
('viewer', 'Read-only access category'),
('finance', 'Financial data access category'),
('hr', 'Human resources access category'),
('it', 'Information technology access category')
ON CONFLICT (name) DO NOTHING;
-- Insert default tags
INSERT INTO user_tags (name, description, color) VALUES
('sensitive', 'Access to sensitive data', '#ff0000'),
('public', 'Public data access', '#00ff00'),
('internal', 'Internal data access', '#ffff00'),
('confidential', 'Confidential data access', '#ff8800'),
('restricted', 'Restricted access', '#8800ff'),
('temporary', 'Temporary access', '#00ffff')
ON CONFLICT (name) DO NOTHING;
-- Insert default RBAC configuration
INSERT INTO rbac_configs (name, description, config_data) VALUES
('default', 'Default RBAC configuration', '{
"rules": [
{
"id": "admin_full_access",
"resource_type": "Database",
"resource_name": "*",
"allowed_roles": ["Admin"],
"allowed_permissions": [],
"required_categories": [],
"required_tags": [],
"deny_categories": [],
"deny_tags": [],
"is_active": true
},
{
"id": "editor_content_access",
"resource_type": "Database",
"resource_name": "content*",
"allowed_roles": ["Editor"],
"allowed_permissions": ["WriteContent"],
"required_categories": ["editor"],
"required_tags": [],
"deny_categories": [],
"deny_tags": ["restricted"],
"is_active": true
}
],
"default_permissions": {
"Database": ["ReadContent"],
"File": ["ReadFile"]
},
"category_hierarchies": {
"admin": ["editor", "viewer"],
"editor": ["viewer"]
},
"tag_hierarchies": {
"public": ["internal"],
"internal": ["confidential"],
"confidential": ["restricted"]
},
"cache_ttl_seconds": 300
}')
ON CONFLICT (name) DO NOTHING;

316
migrations/README.md Normal file
View File

@ -0,0 +1,316 @@
# Database Migrations
This directory contains SQL migration files for the Rustelo application database setup.
## Overview
The migration system sets up a complete database with authentication, content management, and auditing capabilities. **Rustelo now supports both PostgreSQL and SQLite** through a database-agnostic architecture.
## Migration Files
Rustelo provides **database-specific migration files** to support both PostgreSQL and SQLite:
### Database-Specific Files
- `001_initial_setup_postgres.sql` - PostgreSQL-optimized schema
- `001_initial_setup_sqlite.sql` - SQLite-optimized schema
- `002_add_2fa_support_postgres.sql` - PostgreSQL 2FA tables
- `002_add_2fa_support_sqlite.sql` - SQLite 2FA tables
- `003_rbac_system_postgres.sql` - PostgreSQL RBAC system
### Legacy Files
- `001_initial_setup.sql` - Legacy unified file (deprecated)
- `002_add_2fa_support.sql` - Legacy 2FA file (deprecated)
The migration system **automatically detects your database type** from the connection URL and runs the appropriate migration files.
### Schema Features
#### Authentication Tables
- **users** - Core user accounts and profile information
- **user_roles** - User role assignments for RBAC (Role-Based Access Control)
- **oauth_accounts** - External OAuth provider account links
- **sessions** - User session management
- **tokens** - Security tokens for password reset, email verification, etc.
- **permissions** - System permissions for fine-grained access control
- **role_permissions** - Role to permission mappings
- **user_audit_log** - Audit trail for user actions
#### Content Management Tables
- **page_contents** - Main content management table for pages, posts, and other content
#### Key Features
- **UUID Primary Keys** - All tables use UUID primary keys for better security
- **Comprehensive Indexing** - Optimized indexes for performance
- **Full-Text Search** - PostgreSQL full-text search capabilities
- **Audit Logging** - Complete audit trail for user actions
- **Role-Based Access Control** - Flexible permission system
- **Automatic Timestamps** - Automatic created_at/updated_at handling
- **Data Validation** - Comprehensive constraints and validation rules
## Database Schema
### User Management
```sql
users (id, email, username, password_hash, display_name, ...)
user_roles (user_id, role)
oauth_accounts (id, user_id, provider, provider_id, ...)
sessions (id, user_id, expires_at, ...)
tokens (id, user_id, token_type, token_hash, ...)
```
### Authorization
```sql
permissions (id, name, description, resource, action)
role_permissions (role, permission_id)
user_audit_log (id, user_id, action, resource, ...)
```
### Content Management
```sql
page_contents (id, slug, title, content, author_id, ...)
```
## Default Data
The migration includes default data:
### User Roles
- **admin** - Full system access
- **moderator** - Content management and user oversight
- **user** - Basic content creation
- **guest** - Read-only access
### Default Admin User
- **Username**: admin
- **Email**: admin@example.com
- **Password**: admin123 (⚠️ **CHANGE THIS IMMEDIATELY IN PRODUCTION!**)
### Sample Content
- Welcome page
- About page
- Sample blog post
## Functions and Triggers
### Automatic Triggers
- `update_updated_at_column()` - Updates timestamps automatically
- `assign_default_role()` - Assigns default role to new users
### Utility Functions
- `log_user_action()` - Logs user actions for auditing
- `cleanup_expired_auth_data()` - Cleans up expired sessions and tokens
## Running Migrations
### Using the Built-in Migration Runner (Recommended)
```bash
# The application automatically runs migrations on startup
cargo run
# Or use the database tool
cargo run --bin db_tool -- migrate
```
### Database Type Detection
The migration system automatically detects your database type from the `DATABASE_URL`:
```bash
# PostgreSQL - runs *_postgres.sql files
DATABASE_URL=postgresql://user:pass@localhost/database_name
# SQLite - runs *_sqlite.sql files
DATABASE_URL=sqlite:data/app.db
```
### Using SQLx CLI
```bash
# Install sqlx-cli
cargo install sqlx-cli
# PostgreSQL migrations
sqlx migrate run --database-url "postgresql://username:password@localhost/database_name"
# SQLite migrations
sqlx migrate run --database-url "sqlite:data/app.db"
```
### Manual Migration
```bash
# PostgreSQL
psql -U username -d database_name -f 001_initial_setup_postgres.sql
# SQLite
sqlite3 data/app.db < 001_initial_setup_sqlite.sql
```
## Environment Setup
Configure your database connection using the `DATABASE_URL` environment variable:
### PostgreSQL
```env
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
```
### SQLite
```env
DATABASE_URL=sqlite:data/app.db
```
### Additional Database Configuration
```env
# Connection pool settings (optional)
DATABASE_MAX_CONNECTIONS=10
DATABASE_MIN_CONNECTIONS=1
DATABASE_CONNECT_TIMEOUT=30
DATABASE_IDLE_TIMEOUT=600
DATABASE_MAX_LIFETIME=3600
```
## Security Considerations
1. **Change Default Admin Password** - The default admin password is `admin123`
2. **Review Permissions** - Customize role permissions based on your needs
3. **Configure OAuth** - Set up OAuth providers if using external authentication
4. **Enable SSL** - Use SSL connections in production (PostgreSQL)
5. **Backup Strategy** - Implement regular database backups
6. **File Permissions** - Secure SQLite database files (chmod 600)
7. **Database Location** - Store SQLite files outside web root
## Schema Evolution
For future schema changes:
1. Create **database-specific** migration files:
- `003_new_feature_postgres.sql`
- `003_new_feature_sqlite.sql`
2. Always use `CREATE TABLE IF NOT EXISTS` for safety
3. Add proper indexes for performance
4. Handle database-specific differences (UUID vs TEXT, etc.)
5. Test migrations on both database types
6. Include rollback scripts when possible
7. Test migrations on a copy of production data
### Database Differences to Consider
- **UUIDs**: PostgreSQL native vs SQLite TEXT
- **Timestamps**: PostgreSQL native vs SQLite TEXT (ISO 8601)
- **JSON**: PostgreSQL JSONB vs SQLite TEXT
- **Arrays**: PostgreSQL arrays vs SQLite JSON arrays
- **Booleans**: PostgreSQL BOOLEAN vs SQLite INTEGER
## Performance Notes
### PostgreSQL
- All tables have appropriate indexes for common queries
- Full-text search is enabled for content
- Partial indexes are used for filtered queries
- GIN indexes are used for JSONB and array columns
- Connection pooling for high concurrency
### SQLite
- Optimized indexes for single-user scenarios
- WAL mode enabled for better concurrency
- Foreign key constraints enabled
- Query planner optimizations
- Smaller memory footprint
## Troubleshooting
### Common Issues
#### PostgreSQL Issues
1. **Extension Error**: If you get an error about `uuid-ossp` extension, ensure your PostgreSQL user has superuser privileges or the extension is already installed.
2. **Permission Denied**: Ensure your database user has CREATE privileges.
3. **Connection Failed**: Check PostgreSQL service is running and connection details are correct.
#### SQLite Issues
1. **File Permission Error**: Ensure the SQLite file and directory have proper write permissions.
2. **Database Locked**: Close other connections to the SQLite file.
3. **Directory Not Found**: Ensure the directory for the SQLite file exists.
#### General Issues
1. **Constraint Violations**: Check that your data meets the constraint requirements (email format, username format, etc.).
2. **Migration Version Mismatch**: Ensure all migration files are present and properly numbered.
3. **Database Type Detection Failed**: Verify your `DATABASE_URL` format is correct.
### Verification
After running the migration, verify the setup:
#### PostgreSQL Verification
```sql
-- Check tables were created
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';
-- Check default admin user
SELECT username, email, is_active FROM users WHERE username = 'admin';
-- Check permissions setup
SELECT COUNT(*) FROM permissions;
SELECT COUNT(*) FROM role_permissions;
-- Check sample content
SELECT slug, title, state FROM page_contents;
```
#### SQLite Verification
```sql
-- Check tables were created
SELECT name FROM sqlite_master WHERE type='table';
-- Check default admin user
SELECT username, email, is_active FROM users WHERE username = 'admin';
-- Check permissions setup
SELECT COUNT(*) FROM permissions;
SELECT COUNT(*) FROM role_permissions;
-- Check sample content
SELECT slug, title, state FROM page_contents;
```
## Backup and Restore
### PostgreSQL
```bash
# Create backup
pg_dump -U username -d database_name > backup.sql
# Restore backup
psql -U username -d database_name < backup.sql
# Compressed backup
pg_dump -U username -d database_name | gzip > backup.sql.gz
```
### SQLite
```bash
# Create backup (simple copy)
cp data/app.db data/app_backup.db
# Create SQL dump
sqlite3 data/app.db .dump > backup.sql
# Restore from SQL dump
sqlite3 data/app_new.db < backup.sql
# Online backup (while app is running)
sqlite3 data/app.db ".backup data/app_backup.db"
```
### Cross-Database Migration
```bash
# SQLite to PostgreSQL
sqlite3 data/app.db .dump | sed 's/INSERT INTO/INSERT INTO public./g' > backup.sql
psql -U username -d database_name < backup.sql
# Note: May require manual adjustments for data types
```
## Support
For issues or questions:
1. Check the application logs
2. Verify database connectivity
3. Review the migration file for any custom modifications
4. Consult the PostgreSQL documentation for specific errors

View File

@ -0,0 +1,111 @@
📊 **Migration File Comparison**
### **`001_initial_setup.sql` (Comprehensive)**
This is the **full-featured, production-ready** migration that includes:
**🔐 Authentication & Authorization Tables:**
- `users` - Core user accounts with complete profile information
- `user_roles` - Role-based access control (RBAC)
- `oauth_accounts` - External OAuth provider integrations
- `sessions` - Comprehensive session management
- `tokens` - Password reset, email verification tokens
- `permissions` - Fine-grained permission system
- `role_permissions` - Role-to-permission mappings
- `user_audit_log` - Complete audit trail
**📝 Content Management:**
- `page_contents` - Full-featured content management system
**🎯 Advanced Features:**
- Complete PostgreSQL functions and triggers
- Full-text search capabilities
- Comprehensive indexing strategy
- Default admin user and permissions
- Sample content data
### **`001_initial_setup_postgres.sql` (Basic)**
This is a **simplified, basic version** with only:
**🔐 Basic Authentication:**
- `users` - Basic user accounts
- `user_sessions` - Simple session management
- `user_roles` - Basic role system
- `user_role_assignments` - User-role relationships
**📝 Basic Content:**
- `content` - Simple content management
### **`001_initial_setup_sqlite.sql` (Basic)**
Similar to PostgreSQL version but **adapted for SQLite:**
- Same basic table structure
- SQLite-specific data types (TEXT instead of UUID)
- SQLite-specific syntax adaptations
- No advanced PostgreSQL features
## 🤔 **Why This Structure Exists?**
### **1. Flexibility for Different Use Cases**
- **Full Version**: Complete application with all features
- **Basic Versions**: Minimal setup for simple projects or learning
### **2. Database-Specific Optimizations**
- **PostgreSQL**: Leverages advanced features (UUID, JSONB, functions)
- **SQLite**: Optimized for embedded/lightweight usage
### **3. Migration Strategy**
The system likely uses:
```rust
// Pseudocode for migration selection
match database_type {
PostgreSQL => {
if features.includes("full_auth") {
run_migration("001_initial_setup.sql")
} else {
run_migration("001_initial_setup_postgres.sql")
}
}
SQLite => run_migration("001_initial_setup_sqlite.sql")
}
```
## 📋 **Table Count Comparison**
### **Full Version (`001_initial_setup.sql`)**
- ✅ `users` (comprehensive with profile fields)
- ✅ `user_roles` (RBAC)
- ✅ `oauth_accounts` (OAuth integration)
- ✅ `sessions` (detailed session management)
- ✅ `tokens` (password reset, verification)
- ✅ `permissions` (fine-grained permissions)
- ✅ `role_permissions` (role-permission mapping)
- ✅ `user_audit_log` (audit trail)
- ✅ `page_contents` (full CMS)
**Total: 9 tables** + comprehensive functions, triggers, and sample data
### **Basic Versions**
- ✅ `users` (basic fields only)
- ✅ `user_sessions` (simple sessions)
- ✅ `content` (basic content)
- ✅ `user_roles` (basic roles)
- ✅ `user_role_assignments` (role assignments)
**Total: 5 tables** + basic triggers
## 💡 **Recommendation**
For the Rustelo project, I recommend:
1. **Use the full version** (`001_initial_setup.sql`) for production applications
2. **Use basic versions** for prototyping or learning
3. **Consider creating a configuration option** to choose migration complexity:
```bash
# Use full-featured migration
./scripts/db.sh setup create --features full
# Use basic migration
./scripts/db.sh setup create --features basic
```
The full version provides enterprise-grade features like audit logging, OAuth integration, and comprehensive security, while the basic versions are perfect for getting started quickly.

View File

@ -0,0 +1,886 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rustelo_uptime_seconds",
"refId": "A"
}
],
"title": "Application Uptime (seconds)",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rate(rustelo_http_requests_total[5m])",
"refId": "A"
}
],
"title": "HTTP Request Rate",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "histogram_quantile(0.95, rate(rustelo_http_request_duration_seconds_bucket[5m]))",
"refId": "A"
}
],
"title": "HTTP Request Duration (95th percentile)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rustelo_http_requests_in_flight",
"refId": "A"
}
],
"title": "HTTP Requests In Flight",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rustelo_db_connections_active",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rustelo_db_connections_idle",
"refId": "B"
}
],
"title": "Database Connections",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rustelo_memory_usage_bytes",
"refId": "A"
}
],
"title": "Memory Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 24
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rustelo_cpu_usage_percent",
"refId": "A"
}
],
"title": "CPU Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 24
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rate(rustelo_auth_requests_total[5m])",
"refId": "A"
}
],
"title": "Authentication Requests Rate",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 32
},
"id": 9,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rustelo_content_cache_hits_total",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rustelo_content_cache_misses_total",
"refId": "B"
}
],
"title": "Content Cache Performance",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 32
},
"id": 10,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rate(rustelo_email_sent_total[5m])",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rate(rustelo_email_failures_total[5m])",
"refId": "B"
}
],
"title": "Email Service Performance",
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 37,
"style": "dark",
"tags": [
"rustelo",
"application",
"overview"
],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Rustelo Application Overview",
"uid": "rustelo-overview",
"version": 1,
"weekStart": ""
}

View File

@ -0,0 +1,42 @@
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
- name: 'rustelo'
orgId: 1
folder: 'Rustelo'
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards/rustelo
- name: 'system'
orgId: 1
folder: 'System'
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards/system
- name: 'business'
orgId: 1
folder: 'Business Metrics'
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards/business

View File

@ -0,0 +1,37 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
jsonData:
httpMethod: POST
queryTimeout: 60s
timeInterval: 15s
version: 1
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: false
editable: true
jsonData:
maxLines: 1000
timeout: 60s
version: 1
- name: Rustelo Health
type: prometheus
access: proxy
url: http://app:3030/metrics/health
isDefault: false
editable: true
jsonData:
httpMethod: GET
queryTimeout: 30s
timeInterval: 30s
version: 1

67
monitoring/prometheus.yml Normal file
View File

@ -0,0 +1,67 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "rules/*.yml"
scrape_configs:
# Rustelo application metrics
- job_name: 'rustelo-app'
static_configs:
- targets: ['app:3030']
scrape_interval: 15s
metrics_path: '/metrics'
scheme: http
scrape_timeout: 10s
# Prometheus self-monitoring
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# Node exporter for system metrics (optional)
- job_name: 'node-exporter'
static_configs:
- targets: ['node-exporter:9100']
scrape_interval: 15s
# PostgreSQL metrics (optional)
- job_name: 'postgres-exporter'
static_configs:
- targets: ['postgres-exporter:9187']
scrape_interval: 30s
# Redis metrics (optional)
- job_name: 'redis-exporter'
static_configs:
- targets: ['redis-exporter:9121']
scrape_interval: 30s
# Nginx metrics (optional)
- job_name: 'nginx-exporter'
static_configs:
- targets: ['nginx-exporter:9113']
scrape_interval: 30s
# Custom health check endpoint
- job_name: 'rustelo-health'
static_configs:
- targets: ['app:3030']
scrape_interval: 30s
metrics_path: '/health'
scheme: http
scrape_timeout: 10s
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093
# Storage configuration
storage:
tsdb:
path: /prometheus
retention.time: 30d
retention.size: 10GB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

2
public/website.css Normal file
View File

@ -0,0 +1,2 @@
/* layer: preflights */
*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}

9
style/main.scss Normal file
View File

@ -0,0 +1,9 @@
/* layer: preflights */
*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}
/* layer: default */
@keyframes bounce-alt{from,20%,53%,80%,to{animation-timing-function:cubic-bezier(0.215,0.61,0.355,1);transform:translate3d(0,0,0)}40%,43%{animation-timing-function:cubic-bezier(0.755,0.05,0.855,0.06);transform:translate3d(0,-30px,0)}70%{animation-timing-function:cubic-bezier(0.755,0.05,0.855,0.06);transform:translate3d(0,-15px,0)}90%{transform:translate3d(0,-4px,0)}}
.animate-bounce-alt{animation:bounce-alt 1s linear infinite;transform-origin:center bottom;}
.animate-duration-1s{animation-duration:1s;}
.animate-count-1{animation-iteration-count:1;}
.text-5xl{font-size:3rem;line-height:1;}
.font-thin{font-weight:100;}