Skip to content

Commit d06613b

Browse files
Refactor share storage to Redis-backed service
1 parent 29faea6 commit d06613b

9 files changed

Lines changed: 95 additions & 55 deletions

File tree

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ TUNNEL_TOKEN=your_tunnel_token_here
22
SECRET_KEY=your_random_secret_string_here
33

44
TURNSTILE_SITE_KEY=your_turnstile_site_key_here
5-
TURNSTILE_SECRET_KEY=your_turnstile_secret_key_here
5+
TURNSTILE_SECRET_KEY=your_turnstile_secret_key_here
6+
REDIS_URL=redis://redis:6379/0
7+
SHARE_TTL_SECONDS=7200

app/__init__.py

Whitespace-only changes.

app/services/__init__.py

Whitespace-only changes.

app/services/share_service.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import json
2+
import secrets
3+
import string
4+
from typing import Any, Dict, Optional
5+
6+
from app.services.share_store import ShareStore
7+
8+
SHARE_ID_CHARS = string.ascii_letters + string.digits + "-_.~"
9+
SHARE_ID_LENGTH = 15
10+
11+
12+
class ShareService:
13+
def __init__(self, store: ShareStore, ttl_seconds: int):
14+
self.store = store
15+
self.ttl_seconds = ttl_seconds
16+
17+
def create_share(self, data: Dict[str, Any]) -> str:
18+
share_id = ''.join(secrets.choice(SHARE_ID_CHARS) for _ in range(SHARE_ID_LENGTH))
19+
payload = json.dumps(data, ensure_ascii=False)
20+
self.store.save_share(share_id, payload, self.ttl_seconds)
21+
return share_id
22+
23+
def get_share(self, share_id: str) -> Optional[Dict[str, Any]]:
24+
payload = self.store.get_share(share_id)
25+
if payload is None:
26+
return None
27+
return json.loads(payload)
28+
29+
30+
def is_valid_share_id(share_id: str) -> bool:
31+
return all(c in SHARE_ID_CHARS for c in share_id)

app/services/share_store.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Optional
3+
4+
5+
class ShareStore(ABC):
6+
@abstractmethod
7+
def save_share(self, share_id: str, payload: str, ttl: int) -> None:
8+
"""Persist a serialized share payload with TTL in seconds."""
9+
10+
@abstractmethod
11+
def get_share(self, share_id: str) -> Optional[str]:
12+
"""Get serialized share payload, or None if missing/expired."""

app/services/share_store_redis.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Optional
2+
3+
from redis import Redis
4+
5+
from app.services.share_store import ShareStore
6+
7+
8+
class RedisShareStore(ShareStore):
9+
def __init__(self, redis_client: Redis):
10+
self.redis_client = redis_client
11+
12+
@staticmethod
13+
def _key(share_id: str) -> str:
14+
return f"share:{share_id}"
15+
16+
def save_share(self, share_id: str, payload: str, ttl: int) -> None:
17+
self.redis_client.set(self._key(share_id), payload, ex=ttl)
18+
19+
def get_share(self, share_id: str) -> Optional[str]:
20+
value = self.redis_client.get(self._key(share_id))
21+
if value is None:
22+
return None
23+
if isinstance(value, bytes):
24+
return value.decode("utf-8")
25+
return value

docker-compose.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
services:
22
app:
33
image: ghcr.io/${GHCR_IMAGE:-alvin000009238/school_grades}:latest
4-
volumes:
5-
- ./shared_grades:/app/shared_grades
4+
depends_on:
5+
- redis
66
environment:
77
- PYTHONUNBUFFERED=1
88
- SECRET_KEY=${SECRET_KEY}
99
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY}
1010
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}
1111
- TZ=Asia/Taipei
12+
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
13+
- SHARE_TTL_SECONDS=${SHARE_TTL_SECONDS:-7200}
1214

1315
deploy:
1416
restart_policy:
@@ -32,3 +34,10 @@ services:
3234
deploy:
3335
restart_policy:
3436
condition: any
37+
38+
redis:
39+
image: redis:7-alpine
40+
command: redis-server --appendonly yes
41+
deploy:
42+
restart_policy:
43+
condition: any

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ beautifulsoup4
44
gunicorn
55
requests
66
python-dotenv
7+
redis

server.py

Lines changed: 12 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66
import requests as http_requests
77
import logging
88
from logging.handlers import RotatingFileHandler
9+
from redis import Redis
910
from fetcher import GradeFetcher
10-
import time
11-
import threading
12-
import secrets
13-
import string
14-
SHARED_FOLDER = 'shared_grades'
15-
CLEANUP_INTERVAL = 600 # 10 minutes
16-
FILE_LIFETIME = 7200 # 2 hours
11+
from app.services.share_service import ShareService, is_valid_share_id
12+
from app.services.share_store_redis import RedisShareStore
13+
14+
SHARE_TTL_SECONDS = int(os.environ.get('SHARE_TTL_SECONDS', '7200'))
15+
REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379/0')
1716

1817
# Configure logging
1918
logging.basicConfig(level=logging.INFO)
@@ -82,35 +81,8 @@ def verify_turnstile(token, remote_ip, context=''):
8281
return True, None
8382

8483

85-
if not os.path.exists(SHARED_FOLDER):
86-
os.makedirs(SHARED_FOLDER)
87-
88-
def cleanup_thread():
89-
"""Background thread to clean up old shared files."""
90-
while True:
91-
try:
92-
now = time.time()
93-
for filename in os.listdir(SHARED_FOLDER):
94-
file_path = os.path.join(SHARED_FOLDER, filename)
95-
if os.path.isfile(file_path):
96-
if now - os.path.getmtime(file_path) > FILE_LIFETIME:
97-
try:
98-
os.remove(file_path)
99-
logger.info(f"Deleted expired file: {filename}")
100-
except Exception as e:
101-
logger.error(f"Error deleting file {filename}: {e}")
102-
except Exception as e:
103-
logger.error(f"Error in cleanup thread: {e}")
104-
time.sleep(CLEANUP_INTERVAL)
105-
106-
# Start cleanup thread
107-
# In debug mode, Flask's reloader spawns two processes. We only start the thread
108-
# in the child (WERKZEUG_RUN_MAIN='true') to avoid running it twice.
109-
# In production (gunicorn), WERKZEUG_RUN_MAIN is not set, so it always starts.
110-
_is_reloader_parent = app.debug and os.environ.get('WERKZEUG_RUN_MAIN') != 'true'
111-
if not _is_reloader_parent:
112-
threading.Thread(target=cleanup_thread, daemon=True).start()
113-
logger.info("Cleanup thread started")
84+
redis_client = Redis.from_url(REDIS_URL)
85+
share_service = ShareService(store=RedisShareStore(redis_client), ttl_seconds=SHARE_TTL_SECONDS)
11486

11587
@app.route('/')
11688
def index():
@@ -276,15 +248,7 @@ def create_share_link():
276248
if not ok:
277249
return err
278250

279-
# Generate 15-char random URL-safe ID with special chars
280-
# Using A-Z, a-z, 0-9, -, _, ., ~
281-
chars = string.ascii_letters + string.digits + "-_.~"
282-
share_id = ''.join(secrets.choice(chars) for _ in range(15))
283-
284-
file_path = os.path.join(SHARED_FOLDER, f"{share_id}.json")
285-
286-
with open(file_path, 'w', encoding='utf-8') as f:
287-
json.dump(data, f, ensure_ascii=False)
251+
share_id = share_service.create_share(data)
288252

289253
return jsonify({'success': True, 'id': share_id})
290254
except Exception as e:
@@ -294,17 +258,13 @@ def create_share_link():
294258
@app.route('/api/share/<share_id>', methods=['GET'])
295259
def get_shared_grades(share_id):
296260
try:
297-
# Basic validation for ID (alphanumeric + special chars)
298-
if not all(c in string.ascii_letters + string.digits + "-_.~" for c in share_id):
261+
if not is_valid_share_id(share_id):
299262
return jsonify({'error': 'Invalid ID format'}), 400
300263

301-
file_path = os.path.join(SHARED_FOLDER, f"{share_id}.json")
302-
if not os.path.exists(file_path):
264+
data = share_service.get_share(share_id)
265+
if data is None:
303266
return jsonify({'error': 'Link expired or not found'}), 404
304267

305-
with open(file_path, 'r', encoding='utf-8') as f:
306-
data = json.load(f)
307-
308268
return jsonify({'success': True, 'data': data})
309269
except Exception as e:
310270
logger.error(f"Error reading share: {e}")

0 commit comments

Comments
 (0)