No members exist yet. The first account becomes the admin (owner) of Fangtasia.
What the bartender's recommendation card on the homepage shows. Choose a mode below. Off hides the card entirely. Drink shows a featured drink in gothic font ("the bartender suggests / Old Fashioned"). Member shows a member's username (clicking the card opens their profile).
Site-wide ambient music. Plays in a small floating dock in the bottom-left corner of every page. Visitors can play, pause, mute, and collapse it. Supports YouTube and SoundCloud links. Browser autoplay rules mean visitors will need to press play once to start the music.
Members who have departed. Their candle has been put out.
Who made who. Each member traces back to the one who let them through the door.
Names that may not be re-added. Adding a username here prevents anyone from creating a member with that name.
The decree shows in the footer as the house rules. Use a vertical bar | to separate rules onto separate lines. Save changes for the new decree to take effect immediately for all visitors.
Fangtasia needs a free Supabase project to store profile data. Takes about 3 minutes.
-- Fangtasia secure schema. Uses bcrypt + server-side RPC functions.
-- Tables are locked down; nothing can be read/written except via these functions.
-- Clean slate (safe to run on a fresh project; wipes any prior state)
DROP TABLE IF EXISTS napkin_notes CASCADE;
DROP TABLE IF EXISTS sessions CASCADE;
DROP TABLE IF EXISTS login_attempts CASCADE;
DROP TABLE IF EXISTS friends CASCADE;
-- Drop any prior versions of these functions (handles stale signatures from earlier runs)
DROP FUNCTION IF EXISTS get_all_friends() CASCADE;
DROP FUNCTION IF EXISTS bootstrap_first_admin(TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS do_login(TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS do_logout(TEXT) CASCADE;
DROP FUNCTION IF EXISTS verify_session(TEXT) CASCADE;
DROP FUNCTION IF EXISTS update_my_profile(TEXT, TEXT, TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS admin_create_member(TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB) CASCADE;
DROP FUNCTION IF EXISTS admin_update_member(TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB) CASCADE;
DROP FUNCTION IF EXISTS admin_delete_member(TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS admin_set_featured(TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS get_notes_for(TEXT) CASCADE;
DROP FUNCTION IF EXISTS add_note(TEXT, TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS delete_note(TEXT, BIGINT) CASCADE;
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions;
CREATE TABLE friends (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT false,
display_title TEXT NOT NULL DEFAULT '',
avatar TEXT NOT NULL DEFAULT '',
banner TEXT NOT NULL DEFAULT '',
bio TEXT NOT NULL DEFAULT '',
music TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'online',
socials JSONB NOT NULL DEFAULT '[]'::jsonb,
sort_order INT NOT NULL DEFAULT 0,
is_featured BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
username TEXT NOT NULL REFERENCES friends(username) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_sessions_username ON sessions(username);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- Track failed login attempts for server-side rate limiting / lockout
CREATE TABLE login_attempts (
username TEXT PRIMARY KEY,
failed_count INT NOT NULL DEFAULT 0,
locked_until TIMESTAMPTZ
);
-- Cocktail napkin notes left by members on each others' profiles
CREATE TABLE napkin_notes (
id BIGSERIAL PRIMARY KEY,
recipient TEXT NOT NULL REFERENCES friends(username) ON DELETE CASCADE,
author TEXT NOT NULL REFERENCES friends(username) ON DELETE CASCADE,
content TEXT NOT NULL CHECK (length(content) BETWEEN 1 AND 140),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_napkin_recipient ON napkin_notes(recipient, created_at DESC);
ALTER TABLE friends ENABLE ROW LEVEL SECURITY;
ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE login_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE napkin_notes ENABLE ROW LEVEL SECURITY;
-- Public read (no password hashes ever returned)
CREATE OR REPLACE FUNCTION get_all_friends()
RETURNS TABLE(username TEXT, is_admin BOOLEAN, display_title TEXT, avatar TEXT,
banner TEXT, bio TEXT, music TEXT, status TEXT, socials JSONB,
sort_order INT, is_featured BOOLEAN)
LANGUAGE sql SECURITY DEFINER SET search_path = public, extensions AS $$
SELECT f.username, f.is_admin, f.display_title, f.avatar, f.banner, f.bio,
f.music, f.status, f.socials, f.sort_order, f.is_featured
FROM friends f ORDER BY f.sort_order ASC, f.created_at ASC;
$$;
-- Bootstrap (only if no users exist yet) - bcrypt cost 12, min password 12 chars
CREATE OR REPLACE FUNCTION bootstrap_first_admin(p_username TEXT, p_password TEXT)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
BEGIN
IF (SELECT count(*) FROM friends) > 0 THEN
RETURN json_build_object('ok', false, 'error', 'Already initialized'); END IF;
IF NOT (p_username ~ '^[a-z0-9_-]{2,30}$') THEN
RETURN json_build_object('ok', false, 'error', 'Invalid username'); END IF;
IF length(p_password) < 12 THEN
RETURN json_build_object('ok', false, 'error', 'Password must be at least 12 characters'); END IF;
INSERT INTO friends (username, password_hash, is_admin, display_title, sort_order)
VALUES (p_username, extensions.crypt(p_password, extensions.gen_salt('bf', 12)), true, 'the proprietor', 0);
RETURN json_build_object('ok', true);
END; $$;
-- Login: bcrypt verification + server-side lockout (5 fails = 5 min lock)
CREATE OR REPLACE FUNCTION do_login(p_username TEXT, p_password TEXT)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
DECLARE
v_user RECORD;
v_token TEXT;
v_attempts RECORD;
v_lower TEXT;
v_hash_to_check TEXT;
-- Pre-computed dummy bcrypt hash to keep timing constant when username doesn't exist
v_dummy_hash CONSTANT TEXT := '$2a$12$5f5xN5sGI8FpmQxvkqAwSeWJjqLxVjYJqJFmJqAYl9CqWvK/jVeAS';
BEGIN
v_lower := lower(p_username);
SELECT * INTO v_attempts FROM login_attempts WHERE username = v_lower;
IF v_attempts.locked_until IS NOT NULL AND v_attempts.locked_until > now() THEN
PERFORM extensions.crypt(p_password, v_dummy_hash);
RETURN json_build_object('ok', false, 'error', 'Too many failed attempts. Try again later.');
END IF;
SELECT * INTO v_user FROM friends WHERE username = v_lower;
v_hash_to_check := COALESCE(v_user.password_hash, v_dummy_hash);
IF v_user IS NULL OR extensions.crypt(p_password, v_hash_to_check) <> v_hash_to_check THEN
INSERT INTO login_attempts (username, failed_count) VALUES (v_lower, 1)
ON CONFLICT (username) DO UPDATE SET
failed_count = login_attempts.failed_count + 1,
locked_until = CASE
WHEN login_attempts.failed_count + 1 >= 5 THEN now() + interval '5 minutes'
ELSE login_attempts.locked_until END;
RETURN json_build_object('ok', false, 'error', 'Wrong username or password');
END IF;
DELETE FROM login_attempts WHERE username = v_lower;
DELETE FROM sessions WHERE expires_at < now();
v_token := encode(extensions.gen_random_bytes(32), 'hex');
INSERT INTO sessions (token, username, expires_at)
VALUES (v_token, v_user.username, now() + interval '30 days');
RETURN json_build_object('ok', true, 'token', v_token,
'username', v_user.username, 'is_admin', v_user.is_admin);
END; $$;
CREATE OR REPLACE FUNCTION do_logout(p_token TEXT)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
BEGIN DELETE FROM sessions WHERE token = p_token;
RETURN json_build_object('ok', true); END; $$;
CREATE OR REPLACE FUNCTION verify_session(p_token TEXT)
RETURNS TABLE(username TEXT, is_admin BOOLEAN)
LANGUAGE sql SECURITY DEFINER SET search_path = public, extensions AS $$
SELECT f.username, f.is_admin FROM sessions s JOIN friends f ON f.username = s.username
WHERE s.token = p_token AND s.expires_at > now();
$$;
-- Self edit: only avatar / bio / music
CREATE OR REPLACE FUNCTION update_my_profile(p_token TEXT, p_avatar TEXT, p_banner TEXT, p_bio TEXT, p_music TEXT)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
DECLARE v_user TEXT;
BEGIN
SELECT username INTO v_user FROM sessions WHERE token = p_token AND expires_at > now();
IF v_user IS NULL THEN
RETURN json_build_object('ok', false, 'error', 'Session expired. Sign in again.'); END IF;
UPDATE friends SET
avatar = COALESCE(p_avatar, avatar),
banner = COALESCE(p_banner, banner),
bio = COALESCE(p_bio, bio),
music = COALESCE(p_music, music)
WHERE username = v_user;
RETURN json_build_object('ok', true);
END; $$;
-- Admin: create member (bcrypt cost 12, min password 12 chars)
CREATE OR REPLACE FUNCTION admin_create_member(
p_token TEXT, p_username TEXT, p_password TEXT, p_is_admin BOOLEAN,
p_display_title TEXT, p_avatar TEXT, p_banner TEXT, p_bio TEXT, p_music TEXT,
p_status TEXT, p_socials JSONB
) RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
DECLARE v_admin BOOLEAN; v_count INT;
BEGIN
SELECT f.is_admin INTO v_admin FROM sessions s JOIN friends f ON f.username = s.username
WHERE s.token = p_token AND s.expires_at > now();
IF NOT COALESCE(v_admin, false) THEN
RETURN json_build_object('ok', false, 'error', 'Admins only'); END IF;
IF NOT (p_username ~ '^[a-z0-9_-]{2,30}$') THEN
RETURN json_build_object('ok', false, 'error', 'Invalid username'); END IF;
IF length(p_password) < 12 THEN
RETURN json_build_object('ok', false, 'error', 'Password must be at least 12 characters'); END IF;
SELECT count(*) INTO v_count FROM friends;
INSERT INTO friends (username, password_hash, is_admin, display_title,
avatar, banner, bio, music, status, socials, sort_order) VALUES (
p_username, extensions.crypt(p_password, extensions.gen_salt('bf', 12)),
COALESCE(p_is_admin, false), COALESCE(p_display_title, ''),
COALESCE(p_avatar, ''), COALESCE(p_banner, ''),
COALESCE(p_bio, ''), COALESCE(p_music, ''),
COALESCE(p_status, 'online'), COALESCE(p_socials, '[]'::jsonb), v_count);
RETURN json_build_object('ok', true);
EXCEPTION WHEN unique_violation THEN
RETURN json_build_object('ok', false, 'error', 'Username already exists');
END; $$;
-- Admin: update member (any field, password optional)
CREATE OR REPLACE FUNCTION admin_update_member(
p_token TEXT, p_username TEXT, p_password TEXT, p_is_admin BOOLEAN,
p_display_title TEXT, p_avatar TEXT, p_banner TEXT, p_bio TEXT, p_music TEXT,
p_status TEXT, p_socials JSONB
) RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
DECLARE v_admin BOOLEAN;
BEGIN
SELECT f.is_admin INTO v_admin FROM sessions s JOIN friends f ON f.username = s.username
WHERE s.token = p_token AND s.expires_at > now();
IF NOT COALESCE(v_admin, false) THEN
RETURN json_build_object('ok', false, 'error', 'Admins only'); END IF;
IF p_password IS NOT NULL AND length(p_password) > 0 AND length(p_password) < 12 THEN
RETURN json_build_object('ok', false, 'error', 'Password must be at least 12 characters'); END IF;
UPDATE friends SET
is_admin = COALESCE(p_is_admin, is_admin),
display_title = COALESCE(p_display_title, display_title),
avatar = COALESCE(p_avatar, avatar),
banner = COALESCE(p_banner, banner),
bio = COALESCE(p_bio, bio),
music = COALESCE(p_music, music),
status = COALESCE(p_status, status),
socials = COALESCE(p_socials, socials),
password_hash = CASE WHEN p_password IS NOT NULL AND length(p_password) >= 12
THEN extensions.crypt(p_password, extensions.gen_salt('bf', 12))
ELSE password_hash END
WHERE username = p_username;
IF NOT FOUND THEN RETURN json_build_object('ok', false, 'error', 'Not found'); END IF;
RETURN json_build_object('ok', true);
END; $$;
-- Admin: delete member (cannot delete self)
CREATE OR REPLACE FUNCTION admin_delete_member(p_token TEXT, p_username TEXT)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
DECLARE v_caller TEXT; v_admin BOOLEAN;
BEGIN
SELECT s.username, f.is_admin INTO v_caller, v_admin
FROM sessions s JOIN friends f ON f.username = s.username
WHERE s.token = p_token AND s.expires_at > now();
IF NOT COALESCE(v_admin, false) THEN
RETURN json_build_object('ok', false, 'error', 'Admins only'); END IF;
IF p_username = v_caller THEN
RETURN json_build_object('ok', false, 'error', 'Cannot delete yourself'); END IF;
DELETE FROM friends WHERE username = p_username;
DELETE FROM login_attempts WHERE username = p_username;
RETURN json_build_object('ok', true);
END; $$;
-- Admin: set the featured member (only one at a time). Pass NULL to clear.
CREATE OR REPLACE FUNCTION admin_set_featured(p_token TEXT, p_username TEXT)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
DECLARE v_admin BOOLEAN;
BEGIN
SELECT f.is_admin INTO v_admin FROM sessions s JOIN friends f ON f.username = s.username
WHERE s.token = p_token AND s.expires_at > now();
IF NOT COALESCE(v_admin, false) THEN
RETURN json_build_object('ok', false, 'error', 'Admins only'); END IF;
-- Clear all
UPDATE friends SET is_featured = false WHERE is_featured = true;
-- Set the chosen one (if provided)
IF p_username IS NOT NULL THEN
UPDATE friends SET is_featured = true WHERE username = p_username;
IF NOT FOUND THEN
RETURN json_build_object('ok', false, 'error', 'Member not found'); END IF;
END IF;
RETURN json_build_object('ok', true);
END; $$;
-- Public read of notes for a given member (no auth required)
CREATE OR REPLACE FUNCTION get_notes_for(p_recipient TEXT)
RETURNS TABLE(id BIGINT, recipient TEXT, author TEXT, content TEXT, created_at TIMESTAMPTZ)
LANGUAGE sql SECURITY DEFINER SET search_path = public, extensions AS $$
SELECT n.id, n.recipient, n.author, n.content, n.created_at
FROM napkin_notes n WHERE n.recipient = p_recipient
ORDER BY n.created_at DESC LIMIT 30;
$$;
-- Logged-in member adds a note to someone's profile
CREATE OR REPLACE FUNCTION add_note(p_token TEXT, p_recipient TEXT, p_content TEXT)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
DECLARE v_author TEXT; v_recent INT;
BEGIN
SELECT username INTO v_author FROM sessions WHERE token = p_token AND expires_at > now();
IF v_author IS NULL THEN
RETURN json_build_object('ok', false, 'error', 'Sign in to leave a note'); END IF;
IF p_content IS NULL OR length(trim(p_content)) = 0 THEN
RETURN json_build_object('ok', false, 'error', 'Note cannot be empty'); END IF;
IF length(p_content) > 140 THEN
RETURN json_build_object('ok', false, 'error', 'Note must be 140 characters or fewer'); END IF;
-- Anti-spam: max 5 notes per author in last minute
SELECT count(*) INTO v_recent FROM napkin_notes n
WHERE n.author = v_author AND n.created_at > now() - interval '1 minute';
IF v_recent >= 5 THEN
RETURN json_build_object('ok', false, 'error', 'Slow down — too many notes'); END IF;
INSERT INTO napkin_notes (recipient, author, content)
VALUES (p_recipient, v_author, trim(p_content));
RETURN json_build_object('ok', true);
END; $$;
-- Delete a note: author can delete their own, admins can delete any
CREATE OR REPLACE FUNCTION delete_note(p_token TEXT, p_id BIGINT)
RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, extensions AS $$
DECLARE v_caller TEXT; v_admin BOOLEAN; v_author TEXT;
BEGIN
SELECT s.username, f.is_admin INTO v_caller, v_admin
FROM sessions s JOIN friends f ON f.username = s.username
WHERE s.token = p_token AND s.expires_at > now();
IF v_caller IS NULL THEN
RETURN json_build_object('ok', false, 'error', 'Sign in first'); END IF;
SELECT n.author INTO v_author FROM napkin_notes n WHERE n.id = p_id;
IF v_author IS NULL THEN
RETURN json_build_object('ok', false, 'error', 'Note not found'); END IF;
IF v_author <> v_caller AND NOT COALESCE(v_admin, false) THEN
RETURN json_build_object('ok', false, 'error', 'Not your note'); END IF;
DELETE FROM napkin_notes n WHERE n.id = p_id;
RETURN json_build_object('ok', true);
END; $$;
-- Grant function access to anon (the only thing anon can do)
GRANT EXECUTE ON FUNCTION get_all_friends, bootstrap_first_admin, do_login, do_logout,
verify_session, update_my_profile, admin_create_member, admin_update_member,
admin_delete_member, admin_set_featured, get_notes_for, add_note, delete_note TO anon;
-- Force PostgREST to reload its schema cache so the new functions are visible.
-- Done twice with a small delay because occasionally the first signal is missed.
NOTIFY pgrst, 'reload schema';
SELECT pg_sleep(0.1);
NOTIFY pgrst, 'reload schema';
SUPABASE_URL near the bottom, and paste your values