#!/usr/bin/env python3 """ SPORE COMMUNITY HUB Minimal, anonymous, self-cleaning community board for tiny systems Designed to run on router hardware with minimal resources Enhanced with audio player, photo viewer, and Kiwix ZIM support """ import os import json import time import hashlib from datetime import datetime, timedelta from flask import Flask, render_template_string, request, send_file, jsonify, Response import mimetypes import subprocess app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB max file size # Minimal filesystem-based storage BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, 'spore_data') NOTICES_DIR = os.path.join(DATA_DIR, 'notices') PHOTOS_DIR = os.path.join(DATA_DIR, 'photos') MUSIC_DIR = os.path.join(DATA_DIR, 'music') ZIM_DIR = os.path.join(DATA_DIR, 'zim') VOTES_FILE = os.path.join(DATA_DIR, 'votes.json') # Create directories for d in [DATA_DIR, NOTICES_DIR, PHOTOS_DIR, MUSIC_DIR, ZIM_DIR]: os.makedirs(d, exist_ok=True) # Initialize votes file if not os.path.exists(VOTES_FILE): with open(VOTES_FILE, 'w') as f: json.dump({}, f) # Check if kiwix-serve is available KIWIX_AVAILABLE = subprocess.run(['which', 'kiwix-serve'], capture_output=True).returncode == 0 KIWIX_PORT = 8081 # Port for kiwix-serve # Minimal HTML template - single string, no external files needed HTML_TEMPLATE = ''' 🌱 Spore Hub

🌱 SPORE COMMUNITY HUB 🌱

anonymous / ephemeral / free

📢 Community Notices

{% for notice in notices %}
{{ notice.content }}
expires: {{ notice.expires }}
{% endfor %}

📸 Photo Gallery

⬇️ Downvote photos to delete them (3 votes = deletion) | Click photos to view full size

{% for photo in photos %}
community photo
{% endfor %}

🎵 Music Shares

{% for track in music %}
🎵 {{ track }}
{% endfor %}

📚 Knowledge Library (ZIM Files)

{% if kiwix_available %} {% if zim_files %}
📖 Kiwix server is running! Access the library at: http://{{ request.host.split(':')[0] }}:{{ kiwix_port }}

Available ZIM archives:

{% for zim in zim_files %}
📕 {{ zim }}
{% endfor %} {% else %}

No ZIM files found. Place .zim files in the zim directory to make them available.

{% endif %} {% else %}

⚠️ Kiwix-serve not found. Install kiwix-tools to enable offline Wikipedia/knowledge access.

Ubuntu/Debian: sudo apt install kiwix-tools

Or download from: kiwix.org

{% endif %}
''' def get_audio_type(filename): """Get MIME type for audio file""" ext = filename.lower().rsplit('.', 1)[-1] types = { 'mp3': 'audio/mpeg', 'ogg': 'audio/ogg', 'wav': 'audio/wav', 'm4a': 'audio/mp4' } return types.get(ext, 'audio/mpeg') def clean_expired(): """Remove expired notices""" now = time.time() for f in os.listdir(NOTICES_DIR): fpath = os.path.join(NOTICES_DIR, f) try: with open(fpath, 'r') as file: data = json.load(file) if data['expires'] < now: os.remove(fpath) except: pass def get_votes(): """Get current votes""" try: with open(VOTES_FILE, 'r') as f: return json.load(f) except: return {} def save_votes(votes): """Save votes to file""" with open(VOTES_FILE, 'w') as f: json.dump(votes, f) def start_kiwix_server(): """Start kiwix-serve if available and ZIM files exist""" if KIWIX_AVAILABLE: zim_files = [f for f in os.listdir(ZIM_DIR) if f.endswith('.zim')] if zim_files: # Check if already running check = subprocess.run(['pgrep', '-f', 'kiwix-serve'], capture_output=True) if check.returncode != 0: # Start kiwix-serve in background zim_paths = [os.path.join(ZIM_DIR, f) for f in zim_files] cmd = ['kiwix-serve', '--port', str(KIWIX_PORT)] + zim_paths subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print(f"Started kiwix-serve on port {KIWIX_PORT}") @app.route('/') def index(): clean_expired() # Get notices notices = [] for f in sorted(os.listdir(NOTICES_DIR), reverse=True)[:20]: # Last 20 notices try: with open(os.path.join(NOTICES_DIR, f), 'r') as file: data = json.load(file) data['expires'] = datetime.fromtimestamp(data['expires']).strftime('%Y-%m-%d %H:%M') notices.append(data) except: pass # Get photos with vote counts votes = get_votes() photos = [] for f in os.listdir(PHOTOS_DIR): if f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')): photos.append({ 'filename': f, 'votes': votes.get(f, 0) }) # Get music files music = [f for f in os.listdir(MUSIC_DIR) if f.lower().endswith(('.mp3', '.ogg', '.wav', '.m4a'))] # Get ZIM files zim_files = [f for f in os.listdir(ZIM_DIR) if f.endswith('.zim')] return render_template_string(HTML_TEMPLATE, notices=notices, photos=photos, music=music, zim_files=zim_files, kiwix_available=KIWIX_AVAILABLE, kiwix_port=KIWIX_PORT, get_audio_type=get_audio_type) @app.route('/post_notice', methods=['POST']) def post_notice(): content = request.form.get('content', '').strip()[:500] expire_days = min(int(request.form.get('expire_days', 7)), 30) if content: notice_id = hashlib.md5(f"{content}{time.time()}".encode()).hexdigest()[:8] notice_data = { 'content': content, 'posted': time.time(), 'expires': time.time() + (expire_days * 86400) } with open(os.path.join(NOTICES_DIR, f"{notice_id}.json"), 'w') as f: json.dump(notice_data, f) return 'Posted! Back' @app.route('/upload_photo', methods=['POST']) def upload_photo(): if 'photo' in request.files: photo = request.files['photo'] if photo.filename: # Simple filename sanitization ext = photo.filename.rsplit('.', 1)[1].lower() if ext in ['jpg', 'jpeg', 'png', 'gif']: filename = f"{int(time.time())}_{hashlib.md5(photo.filename.encode()).hexdigest()[:8]}.{ext}" photo.save(os.path.join(PHOTOS_DIR, filename)) return 'Uploaded! Back' @app.route('/upload_music', methods=['POST']) def upload_music(): if 'music' in request.files: music = request.files['music'] if music.filename: # Simple filename sanitization ext = music.filename.rsplit('.', 1)[1].lower() if ext in ['mp3', 'ogg', 'wav', 'm4a']: filename = f"{int(time.time())}_{hashlib.md5(music.filename.encode()).hexdigest()[:8]}.{ext}" music.save(os.path.join(MUSIC_DIR, filename)) return 'Uploaded! Back' @app.route('/photo/') def serve_photo(filename): # Basic path traversal protection if '..' in filename or '/' in filename: return "Nice try", 403 return send_file(os.path.join(PHOTOS_DIR, filename)) @app.route('/music/') def serve_music(filename): # Basic path traversal protection if '..' in filename or '/' in filename: return "Nice try", 403 filepath = os.path.join(MUSIC_DIR, filename) # Support range requests for better audio streaming range_header = request.headers.get('range', None) if not range_header: return send_file(filepath) size = os.path.getsize(filepath) byte1, byte2 = 0, None m = re.search('(\d+)-(\d*)', range_header) g = m.groups() if g[0]: byte1 = int(g[0]) if g[1]: byte2 = int(g[1]) length = size - byte1 if byte2 is not None: length = byte2 - byte1 + 1 data = None with open(filepath, 'rb') as f: f.seek(byte1) data = f.read(length) rv = Response(data, 206, mimetype=get_audio_type(filename), direct_passthrough=True) rv.headers.add('Content-Range', f'bytes {byte1}-{byte1 + length - 1}/{size}') rv.headers.add('Accept-Ranges', 'bytes') return rv @app.route('/downvote/', methods=['POST']) def downvote(filename): # Basic path traversal protection if '..' in filename or '/' in filename: return jsonify({'error': 'invalid'}), 403 votes = get_votes() votes[filename] = votes.get(filename, 0) + 1 if votes[filename] >= 3: # Delete the photo try: os.remove(os.path.join(PHOTOS_DIR, filename)) del votes[filename] save_votes(votes) return jsonify({'status': 'deleted'}) except: pass save_votes(votes) return jsonify({'votes': votes[filename]}) if __name__ == '__main__': import re # Add this import for range request handling # Start kiwix server if available start_kiwix_server() # For production on tiny hardware: # - Use lighttpd + flup or uwsgi # - Or run with gunicorn using 1-2 workers # - Disable debug mode! # Example lighttpd config: # server.modules += ( "mod_fastcgi" ) # fastcgi.server = ( # "/spore" => (( # "socket" => "/tmp/spore.sock", # "bin-path" => "/path/to/spore_app.py", # "max-procs" => 1 # )) # ) app.run(host='0.0.0.0', port=8080, debug=False)