From bc34f62fb205f5523ed6fcf892bc6c024c14083e Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 16 Jul 2025 22:39:38 -0700 Subject: [PATCH] first commit --- README.md | 20 ++ main_invite_only.py | 601 ++++++++++++++++++++++++++++++++++++++++++++ ncd.png | Bin 0 -> 7233 bytes 3 files changed, 621 insertions(+) create mode 100644 README.md create mode 100644 main_invite_only.py create mode 100644 ncd.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffd1498 --- /dev/null +++ b/README.md @@ -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. + diff --git a/main_invite_only.py b/main_invite_only.py new file mode 100644 index 0000000..103eaa1 --- /dev/null +++ b/main_invite_only.py @@ -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 = """ + + + + + Access Denied - Secret Santa + + + + +

🚫 Access Denied 🚫

+

{message}

+ + +""" + +# HTML Template with late 90s Christmas theme, inline CSS/JS +# Escaped curly braces for str.format() +HTML = """ + + + + + Secret Santa Coordinator - Ho Ho Ho! + + + + +
+
+

🎅 Secret Santa Anonymous Coordinator 🎄

+

Welcome to our #darkfedi gift exchange! Enter your details to join the pool. We'll assign you someone to send a gift to anonymously.

+

Signup deadline: {signup_deadline} | Drawing deadline: {drawing_deadline}

+ {message} +
+
+
+
+
+
+
+
+ *CLICK* to agree to reach out to the organizer if you cannot send a gift in time. + +

+ +
+

Check Your Status

+
+
+ +
+ + + +""" + +# Simplified success HTML (removes forms and other elements) +SUCCESS_HTML = """ + + + + + Secret Santa Coordinator - Ho Ho Ho! + + + + +
+
+

🎅 Secret Santa Anonymous Coordinator 🎄

+ {message} + + + + +""" + +# 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 = "

You've already joined! Use the check form below with your code to see status.

" + 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 = "

Signup period is over!

" + 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 = "

Address is required!

" + 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 = "

Either Fedi handle or email is required for contact!

" + html = SUCCESS_HTML.format(message=message) + return web.Response(text=html, content_type='text/html', status=400) + if not data.get('agreement'): + message = "

You must agree to the terms!

" + 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 = "

You've already submitted! Check your status below.

" + 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"

Thanks for joining! Your one-time code: {code} (save it!)

{assignment_msg}

If not assigned, check back later with your code.

" + + 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 "

Drawing period is over!

" + + 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"

You've already been assigned! Send to: Name: {receiver['name']}, Address: {receiver['address']}, Limit: ${receiver['dollar_limit']}, Likes/Dislikes: {receiver['likes_dislikes']}

" + + available = [eid for eid in pool if eid != entry['id']] + if not available: + return "

Pool is empty right now (or only you left). Check back later!

" + + if len(pool) < 3 and time_in_pool <= COUPLE_HOURS: + return "

Pool is too small (<3 participants). Please check back in a couple hours.

" + + # 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"

Assigned! Send a gift to: Name: {receiver_entry['name']}, Address: {receiver_entry['address']}, Limit: ${receiver_entry['dollar_limit']}, Likes/Dislikes: {receiver_entry['likes_dislikes']}

" + +@routes.post('/check') +async def check(request): + now = datetime.utcnow() + data = await request.post() + code = data.get('code') + if not code: + message = "

Code required!

" + 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 = "

Invalid code!

" + 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"

Your code is valid. Status: {assignment_msg}

" + + 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) diff --git a/ncd.png b/ncd.png new file mode 100644 index 0000000000000000000000000000000000000000..b432cc0b1ad122bac26a20fad44656f1cb59027f GIT binary patch literal 7233 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 literal 0 HcmV?d00001