first commit

This commit is contained in:
2025-07-16 22:39:38 -07:00
commit bc34f62fb2
3 changed files with 621 additions and 0 deletions

20
README.md Normal file
View File

@ -0,0 +1,20 @@
## NCD Secret Santa
You need to install aiohttp from your repos or make venv and install it via pip.
Before you run it, modify the BASE_URL var in the main file to wherever this will be publically accessable, for making the invite code urls.
When you first run it, it will make a `invite_urls.txt` file with 500 invite urls. They're one-time only and are bound to a user's cookie, so they can't
clear their cache or browse private mode or use a different device for checking later if they have drawn another person yet. You are manually
responsible for only giving out good invite codes.
There are two deadlines, one for registering, and the other for drawing. Those are configurable in the top of the file, but default to nov 15 and dec 15 respectively.
If it's past the drawing deadline, a log file should be created with a list of those who signed up but haven't drawn someone to send a gift to.
You are responsible for contacting them and reminding them, and if they cancel, tell the person who drew them that they are being reassigned.
To reassign, just take someone out of the `pool.json` and give the address to the person.
If there's some kind of weird bug and everyone needs to try again, `persistant.json` keeps a temporary record of everyone who signed up, but it will be
automatically deleted after Christmas.

601
main_invite_only.py Normal file
View File

@ -0,0 +1,601 @@
import asyncio
import json
import os
import uuid
from datetime import datetime, timedelta
from aiohttp import web
import random
import base64
# Constants and Config
BASE_URL = 'rpi.local:8080'
PERSISTENT_FILE = 'persistent.json'
POOL_FILE = 'pool.json'
LOG_FILE = 'secretsanta_noncompliant_log.txt'
TOKENS_FILE = 'tokens.json'
INVITE_URLS_FILE = 'invite_urls.txt'
COUPLE_HOURS = 2 # hours to wait before forcing assignment
DEFAULT_DOLLAR_LIMIT = 50
CHECK_INTERVAL = 12 * 3600 # 12 hours in seconds
# Deadlines default to current year
CURRENT_YEAR = datetime.utcnow().year
SIGNUP_DEADLINE = datetime(CURRENT_YEAR, 11, 15, 0, 0, 0)
DRAWING_DEADLINE = datetime(CURRENT_YEAR, 12, 15, 0, 0, 0)
# Calculate purge date: 7 days before Christmas of current year
CHRISTMAS = datetime(CURRENT_YEAR, 12, 25)
PURGE_DATE = CHRISTMAS - timedelta(days=7)
# Load data
def load_persistent():
if os.path.exists(PERSISTENT_FILE):
with open(PERSISTENT_FILE, 'r') as f:
return json.load(f)
return []
def load_pool():
if os.path.exists(POOL_FILE):
with open(POOL_FILE, 'r') as f:
return json.load(f)
return []
def load_tokens():
if os.path.exists(TOKENS_FILE):
with open(TOKENS_FILE, 'r') as f:
return json.load(f)
return {}
persistent = load_persistent()
pool = load_pool() # list of ids available to be drawn (not yet assigned_by someone)
tokens = load_tokens() # dict of token -> {used: bool, used_by_cookie: str}
# Save data
def save_data():
with open(PERSISTENT_FILE, 'w') as f:
json.dump(persistent, f, indent=4, default=str)
with open(POOL_FILE, 'w') as f:
json.dump(pool, f, indent=4)
def save_tokens():
with open(TOKENS_FILE, 'w') as f:
json.dump(tokens, f, indent=4)
# Generate invite tokens
def generate_tokens(count=500, base_url=BASE_URL):
"""Generate invite tokens and save URLs to file"""
new_tokens = {}
urls = []
for _ in range(count):
token = base64.urlsafe_b64encode(uuid.uuid4().bytes).decode('utf-8').rstrip('=')
new_tokens[token] = {'used': False, 'used_by_cookie': None}
urls.append(f"{base_url}/invite?token={token}")
# Save tokens
tokens.update(new_tokens)
save_tokens()
# Save URLs to text file
with open(INVITE_URLS_FILE, 'w') as f:
f.write('\n'.join(urls))
print(f"Generated {count} invite tokens. URLs saved to {INVITE_URLS_FILE}")
# Check if we need to generate tokens on startup
if not os.path.exists(TOKENS_FILE) or not tokens:
generate_tokens()
# Purge data if past purge date
def purge_data():
now = datetime.utcnow()
if now > PURGE_DATE:
for file in [PERSISTENT_FILE, POOL_FILE]:
if os.path.exists(file):
os.remove(file)
print("Data purged due to purge date.")
# Log non-compliant (non-drawers) if past drawing deadline
def log_non_compliant():
now = datetime.utcnow()
if now > DRAWING_DEADLINE:
non_drawers = [entry for entry in persistent if entry.get('assigned_receiver') is None]
log_lines = []
for nd in non_drawers:
contact = nd.get('fedi') or nd.get('email') or 'No contact'
log_lines.append(f"Non-drawer: ID {nd['id']}, Contact: {contact}")
if nd.get('assigned_by'):
sender = next((e for e in persistent if e['id'] == nd['assigned_by']), None)
sender_contact = sender.get('fedi') or sender.get('email') or 'No contact' if sender else 'Unknown'
log_lines.append(f" Affected sender ID {nd['assigned_by']}, Contact: {sender_contact} - Needs reassignment")
if log_lines:
log_msg = "\n".join(log_lines) + "\n"
print(log_msg) # To console
with open(LOG_FILE, 'a') as f:
f.write(log_msg)
# Background task for periodic checks
async def background_checker():
while True:
purge_data()
log_non_compliant()
await asyncio.sleep(CHECK_INTERVAL)
# HTML Template for access denied
ACCESS_DENIED_HTML = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Access Denied - Secret Santa</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🚫</text></svg>">
<style>
body {{
background-color: #8B0000;
color: white;
font-family: 'Comic Sans MS', cursive;
text-align: center;
margin: 0;
padding: 20px;
}}
h1 {{ color: #FFD700; }}
</style>
</head>
<body>
<h1>🚫 Access Denied 🚫</h1>
<p>{message}</p>
</body>
</html>
"""
# HTML Template with late 90s Christmas theme, inline CSS/JS
# Escaped curly braces for str.format()
HTML = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Secret Santa Coordinator - Ho Ho Ho!</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎁</text></svg>">
<style>
body {{
background-color: #228B22; /* Forest green */
color: white;
font-family: 'Comic Sans MS', cursive;
text-align: center;
margin: 0;
padding: 20px;
border: 20px solid;
border-image: linear-gradient(to right, #FF0000 50%, #FFFFFF 50%) 1; /* Alternating red/white for Christmas colors */
animation: twinkling 2s infinite;
}}
h1 {{ color: #FF0000; text-shadow: 2px 2px #FFD700; }}
form {{ background: rgba(255,255,255,0.2); padding: 20px; margin: 20px auto; width: 50%; border-radius: 10px; }}
input, textarea {{ width: 80%; padding: 10px; margin: 10px; background: #FFF; color: #000; border: 2px solid #FF0000; }}
button {{ background: #FF0000; color: white; padding: 10px 20px; border: none; cursor: pointer; }}
button:hover {{ background: #FFD700; color: #FF0000; }}
#snow {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }}
#candycanes {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }}
.agreement-div {{
background: rgba(255,255,255,0.2);
padding: 10px;
margin: 10px auto;
width: 80%;
border: 2px dashed #FFD700;
border-radius: 5px;
cursor: pointer;
}}
.agreement-div input {{ display: none; }}
.agreement-div.checked {{ background: rgba(0,255,0,0.3); }}
@keyframes twinkling {{ 0% {{ opacity: 1; }} 50% {{ opacity: 0.8; }} 100% {{ opacity: 1; }} }}
</style>
</head>
<body>
<div id="snow"></div>
<div id="candycanes"></div>
<h1>🎅 Secret Santa Anonymous Coordinator 🎄</h1>
<p>Welcome to our #darkfedi gift exchange! Enter your details to join the pool. We'll assign you someone to send a gift to anonymously.</p>
<p>Signup deadline: {signup_deadline} | Drawing deadline: {drawing_deadline}</p>
<b>{message}</b>
<form id="submit-form" method="POST" action="/submit">
<input type="text" name="name" placeholder="Name (optional)"><br>
<input type="text" name="fedi" placeholder="Fedi Handle (e.g., @user@mastodon.social, optional but required if no email)"><br>
<input type="email" name="email" placeholder="Email (optional but required if no Fedi)"><br>
<input type="number" name="dollar_limit" placeholder="Dollar Limit (default $50)"><br>
<textarea name="likes_dislikes" placeholder="Likes/Dislikes (optional)"></textarea><br>
<textarea name="address" placeholder="Address (required)" required></textarea><br>
<div class="agreement-div" onclick="toggleCheckbox()">
*CLICK* to agree to reach out to the organizer if you cannot send a gift in time.
<input type="checkbox" name="agreement" required>
</div><br>
<button type="submit">Join Secret Santa!</button>
</form>
<h2>Check Your Status</h2>
<form id="check-form" method="POST" action="/check">
<input type="text" name="code" placeholder="Your One-Time Code" required><br>
<button type="submit">Check / Draw</button>
</form>
<script>
// Toggle checkbox and class
function toggleCheckbox() {{
const div = document.querySelector('.agreement-div');
const checkbox = div.querySelector('input');
checkbox.checked = !checkbox.checked;
div.classList.toggle('checked', checkbox.checked);
}}
// Inline falling snow JS
function createSnowflake() {{
const snowflake = document.createElement('div');
snowflake.innerHTML = '';
snowflake.style.position = 'absolute';
snowflake.style.fontSize = Math.random() * 20 + 10 + 'px';
snowflake.style.color = 'white';
snowflake.style.left = Math.random() * window.innerWidth + 'px';
snowflake.style.top = '-50px';
snowflake.style.opacity = Math.random();
document.getElementById('snow').appendChild(snowflake);
let y = -50;
const speed = Math.random() * 3 + 1;
const sway = Math.random() * 20 - 10;
const interval = setInterval(() => {{
y += speed;
snowflake.style.top = y + 'px';
snowflake.style.left = (parseFloat(snowflake.style.left) + Math.sin(y / 50) * sway / 10) + 'px';
if (y > window.innerHeight) {{
snowflake.remove();
clearInterval(interval);
}}
}}, 50);
}}
setInterval(createSnowflake, 200);
// Candy cane sprinkling (assuming ncd.png is served at /candycane.png)
function createCandyCane() {{
const img = document.createElement('img');
img.src = '/candycane.png';
img.style.position = 'absolute';
img.style.width = Math.random() * 50 + 30 + 'px';
img.style.left = Math.random() * window.innerWidth + 'px';
img.style.top = '-100px';
img.style.opacity = Math.random() * 0.5 + 0.5;
img.style.transform = `rotate(${{Math.random() * 40 - 20}}deg)`;
document.getElementById('candycanes').appendChild(img);
let y = -100;
const speed = Math.random() * 2 + 1;
const shiftInterval = Math.random() * 2000 + 1000; // Flip/shift every 1-3s
let flipped = false;
const interval = setInterval(() => {{
y += speed;
img.style.top = y + 'px';
if (y > window.innerHeight) {{
img.remove();
clearInterval(interval);
}}
}}, 50);
setInterval(() => {{
flipped = !flipped;
img.style.transform = `rotate(${{Math.random() * 40 - 20}}deg) scaleX(${{flipped ? -1 : 1}})`;
}}, shiftInterval);
}}
setInterval(createCandyCane, 1000); // Slower than snow
</script>
</body>
</html>
"""
# Simplified success HTML (removes forms and other elements)
SUCCESS_HTML = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Secret Santa Coordinator - Ho Ho Ho!</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎁</text></svg>">
<style>
body {{
background-color: #228B22; /* Forest green */
color: white;
font-family: 'Comic Sans MS', cursive;
text-align: center;
margin: 0;
padding: 20px;
border: 20px solid;
border-image: linear-gradient(to right, #FF0000 50%, #FFFFFF 50%) 1; /* Alternating red/white */
animation: twinkling 2s infinite;
}}
h1 {{ color: #FF0000; text-shadow: 2px 2px #FFD700; }}
#snow {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }}
#candycanes {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }}
button {{ background: #FF0000; color: white; padding: 10px 20px; border: none; cursor: pointer; }}
button:hover {{ background: #FFD700; color: #FF0000; }}
@keyframes twinkling {{ 0% {{ opacity: 1; }} 50% {{ opacity: 0.8; }} 100% {{ opacity: 1; }} }}
</style>
</head>
<body>
<div id="snow"></div>
<div id="candycanes"></div>
<h1>🎅 Secret Santa Anonymous Coordinator 🎄</h1>
{message}
<button onclick="window.location.href='/'">Back to Main Page</button>
<script>
// Snow and candy cane scripts same as above
function createSnowflake() {{
const snowflake = document.createElement('div');
snowflake.innerHTML = '';
snowflake.style.position = 'absolute';
snowflake.style.fontSize = Math.random() * 20 + 10 + 'px';
snowflake.style.color = 'white';
snowflake.style.left = Math.random() * window.innerWidth + 'px';
snowflake.style.top = '-50px';
snowflake.style.opacity = Math.random();
document.getElementById('snow').appendChild(snowflake);
let y = -50;
const speed = Math.random() * 3 + 1;
const sway = Math.random() * 20 - 10;
const interval = setInterval(() => {{
y += speed;
snowflake.style.top = y + 'px';
snowflake.style.left = (parseFloat(snowflake.style.left) + Math.sin(y / 50) * sway / 10) + 'px';
if (y > window.innerHeight) {{
snowflake.remove();
clearInterval(interval);
}}
}}, 50);
}}
setInterval(createSnowflake, 200);
function createCandyCane() {{
const img = document.createElement('img');
img.src = '/ncd.png';
img.style.position = 'absolute';
img.style.width = Math.random() * 50 + 30 + 'px';
img.style.left = Math.random() * window.innerWidth + 'px';
img.style.top = '-100px';
img.style.opacity = Math.random() * 0.5 + 0.5;
img.style.transform = `rotate(${{Math.random() * 40 - 20}}deg)`;
document.getElementById('candycanes').appendChild(img);
let y = -100;
const speed = Math.random() * 2 + 1;
const shiftInterval = Math.random() * 2000 + 1000;
let flipped = false;
const interval = setInterval(() => {{
y += speed;
img.style.top = y + 'px';
if (y > window.innerHeight) {{
img.remove();
clearInterval(interval);
}}
}}, 50);
setInterval(() => {{
flipped = !flipped;
img.style.transform = `rotate(${{Math.random() * 40 - 20}}deg) scaleX(${{flipped ? -1 : 1}})`;
}}, shiftInterval);
}}
setInterval(createCandyCane, 1000);
</script>
</body>
</html>
"""
# Middleware to check authentication
@web.middleware
async def auth_middleware(request, handler):
# Allow access to invite endpoint and static resources
if request.path in ['/invite', '/candycane.png']:
return await handler(request)
# Check for auth cookie
auth_cookie = request.cookies.get('secretsanta_auth')
if not auth_cookie:
html = ACCESS_DENIED_HTML.format(message="You need a valid invite link to access this page.")
return web.Response(text=html, content_type='text/html', status=403)
# Verify the auth cookie is valid (exists in our tokens and is marked as used)
valid = False
for token, data in tokens.items():
if data.get('used_by_cookie') == auth_cookie:
valid = True
break
if not valid:
html = ACCESS_DENIED_HTML.format(message="Invalid or expired access token.")
return web.Response(text=html, content_type='text/html', status=403)
return await handler(request)
# Helper to get entry by id
def get_entry_by_id(entry_id):
return next((e for e in persistent if e['id'] == entry_id), None)
# Helper to get entry by code
def get_entry_by_code(code):
return next((e for e in persistent if e.get('code') == code), None)
# Routes
routes = web.RouteTableDef()
@routes.get('/invite')
async def invite(request):
token = request.query.get('token')
if not token:
html = ACCESS_DENIED_HTML.format(message="No invite token provided.")
return web.Response(text=html, content_type='text/html', status=400)
token_data = tokens.get(token)
if not token_data:
html = ACCESS_DENIED_HTML.format(message="Invalid invite token.")
return web.Response(text=html, content_type='text/html', status=400)
if token_data['used']:
html = ACCESS_DENIED_HTML.format(message="This invite token has already been used.")
return web.Response(text=html, content_type='text/html', status=400)
# Mark token as used and create auth cookie
auth_cookie = str(uuid.uuid4())
token_data['used'] = True
token_data['used_by_cookie'] = auth_cookie
save_tokens()
# Redirect to main page with auth cookie
resp = web.Response(status=302)
resp.headers['Location'] = '/'
resp.set_cookie('secretsanta_auth', auth_cookie, max_age=3600*24*30) # 30 days
return resp
@routes.get('/')
async def index(request):
now = datetime.utcnow()
message = ""
cookie_code = request.cookies.get('secretsanta_code')
if cookie_code:
entry = get_entry_by_code(cookie_code)
if entry:
message = "<p>You've already joined! Use the check form below with your code to see status.</p>"
html = HTML.format(
signup_deadline=SIGNUP_DEADLINE.isoformat(),
drawing_deadline=DRAWING_DEADLINE.isoformat(),
message=message
)
resp = web.Response(text=html, content_type='text/html')
return resp
@routes.get('/candycane.png')
async def serve_candycane(request): # actually ncd icon
if os.path.exists('ncd.png'):
with open('ncd.png', 'rb') as f:
data = f.read()
return web.Response(body=data, content_type='image/png')
return web.Response(status=404)
@routes.post('/submit')
async def submit(request):
now = datetime.utcnow()
if now > SIGNUP_DEADLINE:
message = "<p>Signup period is over!</p>"
html = SUCCESS_HTML.format(message=message)
return web.Response(text=html, content_type='text/html', status=400)
data = await request.post()
if not data.get('address'):
message = "<p>Address is required!</p>"
html = SUCCESS_HTML.format(message=message)
return web.Response(text=html, content_type='text/html', status=400)
if not data.get('fedi') and not data.get('email'):
message = "<p>Either Fedi handle or email is required for contact!</p>"
html = SUCCESS_HTML.format(message=message)
return web.Response(text=html, content_type='text/html', status=400)
if not data.get('agreement'):
message = "<p>You must agree to the terms!</p>"
html = SUCCESS_HTML.format(message=message)
return web.Response(text=html, content_type='text/html', status=400)
# Check for existing via cookie
cookie_code = request.cookies.get('secretsanta_code')
if cookie_code and get_entry_by_code(cookie_code):
message = "<p>You've already submitted! Check your status below.</p>"
html = SUCCESS_HTML.format(message=message)
return web.Response(text=html, content_type='text/html', status=400)
entry_id = str(uuid.uuid4())[:8] # First 8 chars of UUID
code = str(uuid.uuid4())[:8] # Short code too
dollar_limit = data.get('dollar_limit') or DEFAULT_DOLLAR_LIMIT
try:
dollar_limit = float(dollar_limit)
except ValueError:
dollar_limit = DEFAULT_DOLLAR_LIMIT
entry = {
'id': entry_id,
'name': data.get('name', ''),
'fedi': data.get('fedi', ''),
'email': data.get('email', ''),
'dollar_limit': dollar_limit,
'likes_dislikes': data.get('likes_dislikes', ''),
'address': data.get('address'),
'submission_time': now.isoformat(),
'code': code,
'assigned_receiver': None,
'assigned_by': None
}
persistent.append(entry)
pool.append(entry_id)
save_data()
print(f'Created Entry:\n{entry}\n\n')
# Attempt immediate assignment if pool >=3 (including this one)
assignment_msg = await try_assign(entry)
message = f"<p>Thanks for joining! Your one-time code: <strong>{code}</strong> (save it!)</p>{assignment_msg}<p>If not assigned, check back later with your code.</p>"
html = SUCCESS_HTML.format(message=message)
resp = web.Response(text=html, content_type='text/html')
resp.set_cookie('secretsanta_code', code, max_age=3600*24*30) # 30 days
return resp
async def try_assign(entry):
now = datetime.utcnow()
if now > DRAWING_DEADLINE:
return "<p>Drawing period is over!</p>"
submission_time = datetime.fromisoformat(entry['submission_time'])
time_in_pool = (now - submission_time).total_seconds() / 3600
if entry['assigned_receiver'] is not None:
receiver = get_entry_by_id(entry['assigned_receiver'])
if receiver:
return f"<p>You've already been assigned! Send to: Name: {receiver['name']}, Address: {receiver['address']}, Limit: ${receiver['dollar_limit']}, Likes/Dislikes: {receiver['likes_dislikes']}</p>"
available = [eid for eid in pool if eid != entry['id']]
if not available:
return "<p>Pool is empty right now (or only you left). Check back later!</p>"
if len(pool) < 3 and time_in_pool <= COUPLE_HOURS:
return "<p>Pool is too small (<3 participants). Please check back in a couple hours.</p>"
# Assign random
receiver_id = random.choice(available)
receiver_entry = get_entry_by_id(receiver_id)
receiver_entry['assigned_by'] = entry['id']
entry['assigned_receiver'] = receiver_id
pool.remove(receiver_id)
save_data()
print(f'Assigned id {receiver_id}')
return f"<p>Assigned! Send a gift to: Name: {receiver_entry['name']}, Address: {receiver_entry['address']}, Limit: ${receiver_entry['dollar_limit']}, Likes/Dislikes: {receiver_entry['likes_dislikes']}</p>"
@routes.post('/check')
async def check(request):
now = datetime.utcnow()
data = await request.post()
code = data.get('code')
if not code:
message = "<p>Code required!</p>"
html = SUCCESS_HTML.format(message=message)
return web.Response(text=html, content_type='text/html', status=400)
entry = get_entry_by_code(code)
if not entry:
message = "<p>Invalid code!</p>"
html = SUCCESS_HTML.format(message=message)
return web.Response(text=html, content_type='text/html', status=400)
assignment_msg = await try_assign(entry)
message = f"<p>Your code is valid. Status: {assignment_msg}</p>"
html = SUCCESS_HTML.format(message=message)
resp = web.Response(text=html, content_type='text/html')
resp.set_cookie('secretsanta_code', code, max_age=3600*24*30)
return resp
# App setup
app = web.Application(middlewares=[auth_middleware])
app.add_routes(routes)
async def start_background_tasks(app):
app['background_task'] = asyncio.create_task(background_checker())
app.on_startup.append(start_background_tasks)
if __name__ == '__main__':
web.run_app(app, port=8080)

BIN
ncd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB