#!/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 """ import os import json import time import hashlib import subprocess from datetime import datetime, timedelta from flask import Flask, render_template_string, request, send_file, jsonify, abort import mimetypes 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') # Kiwix configuration KIWIX_PORT = 8081 # Port for kiwix-serve KIWIX_PID_FILE = os.path.join(DATA_DIR, 'kiwix.pid') # 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) # 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 %}

📚 Offline Wikipedia & Knowledge

Access complete offline encyclopedias:

{% for zim in zim_files[:5] %} {% endfor %}
{% if zim_count > 5 %} View all {{ zim_count }} knowledge archives → {% endif %}

📸 Photo Gallery

⬇️ Downvote photos to delete them (3 votes = deletion)

{% for photo in photos[:6] %}
community photo
{% endfor %}
{% if photo_count > 6 %} View all {{ photo_count }} photos → {% endif %}

🎵 Music Shares

⬇️ Downvote tracks to delete them (3 votes = deletion)

{% for track in music[:5] %}
🎵 {{ track.filename }}
{% endfor %}
{% if music_count > 5 %} View all {{ music_count }} tracks → {% endif %}

Free RAM: %d bytes | Mode: %s

''' # Directory listing template DIR_LISTING_TEMPLATE = ''' Browse {{ title }} ← Back to Hub

{{ title }}

{% if type == 'photos' %}
{% for item in items %}
{{ item.filename }}
{{ item.filename }}
{% endfor %}
{% else %}
{% for item in items %}
{% if type == 'zim' %} 📖 {{ item }}
Size: {{ "%.1f MB" % (sizes.get(item, 0) / 1048576) }}
{% elif type == 'music' %} 🎵 {{ item.filename }} {% endif %}
{% endfor %}
{% endif %} ''' def start_kiwix_server(): """Start kiwix-serve if not running""" # Check if already running if os.path.exists(KIWIX_PID_FILE): try: with open(KIWIX_PID_FILE, 'r') as f: pid = int(f.read()) # Check if process exists os.kill(pid, 0) return # Already running except: pass # Not running, continue to start # Start kiwix-serve try: process = subprocess.Popen([ 'kiwix-serve', '--port', str(KIWIX_PORT), '--zimPath', ZIM_DIR, '--daemon' ]) # Save PID with open(KIWIX_PID_FILE, 'w') as f: f.write(str(process.pid)) print(f"Started kiwix-serve on port {KIWIX_PORT}") except Exception as e: print(f"Failed to start kiwix-serve: {e}") def restart_kiwix_server(): """Restart kiwix-serve to pick up new files""" # Kill existing process if os.path.exists(KIWIX_PID_FILE): try: with open(KIWIX_PID_FILE, 'r') as f: pid = int(f.read()) os.kill(pid, 15) # SIGTERM time.sleep(1) except: pass os.remove(KIWIX_PID_FILE) # Start new instance start_kiwix_server() 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 get_file_sizes(directory): """Get file sizes for a directory""" sizes = {} for f in os.listdir(directory): try: fpath = os.path.join(directory, f) sizes[f] = os.path.getsize(fpath) except: sizes[f] = 0 return sizes @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'photo_{f}', 0) }) photo_count = len(photos) # Get music files with vote counts music = [] for f in os.listdir(MUSIC_DIR): if f.lower().endswith(('.mp3', '.ogg', '.wav', '.m4a')): music.append({ 'filename': f, 'votes': votes.get(f'music_{f}', 0) }) music_count = len(music) # Get ZIM files zim_files = [f for f in os.listdir(ZIM_DIR) if f.endswith('.zim')] zim_count = len(zim_files) # Check memory try: with open('/proc/meminfo', 'r') as f: for line in f: if line.startswith('MemAvailable:'): free_ram = int(line.split()[1]) * 1024 break except: free_ram = 0 mode = "Spore Hub Active" return render_template_string(HTML_TEMPLATE, notices=notices, photos=photos[:6], photo_count=photo_count, music=music[:5], music_count=music_count, zim_files=zim_files[:5], zim_count=zim_count, kiwix_port=KIWIX_PORT) % (free_ram, mode) @app.route('/browse/') def browse(category): votes = get_votes() if category == 'photos': items = [] for f in os.listdir(PHOTOS_DIR): if f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')): items.append({ 'filename': f, 'votes': votes.get(f'photo_{f}', 0) }) return render_template_string(DIR_LISTING_TEMPLATE, title="All Photos", type='photos', items=items) elif category == 'music': items = [] for f in os.listdir(MUSIC_DIR): if f.lower().endswith(('.mp3', '.ogg', '.wav', '.m4a')): items.append({ 'filename': f, 'votes': votes.get(f'music_{f}', 0) }) return render_template_string(DIR_LISTING_TEMPLATE, title="All Music", type='music', items=items, kiwix_port=KIWIX_PORT) elif category == 'zim': items = [f for f in os.listdir(ZIM_DIR) if f.endswith('.zim')] sizes = get_file_sizes(ZIM_DIR) return render_template_string(DIR_LISTING_TEMPLATE, title="All Offline Knowledge Archives", type='zim', items=items, sizes=sizes, kiwix_port=KIWIX_PORT) else: abort(404) @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 return send_file(os.path.join(MUSIC_DIR, filename)) @app.route('/downvote//', methods=['POST']) def downvote(media_type, filename): # Basic path traversal protection if '..' in filename or '/' in filename: return jsonify({'error': 'invalid'}), 403 votes = get_votes() vote_key = f"{media_type}_{filename}" votes[vote_key] = votes.get(vote_key, 0) + 1 if votes[vote_key] >= 3: # Delete the file try: if media_type == 'photo': os.remove(os.path.join(PHOTOS_DIR, filename)) elif media_type == 'music': os.remove(os.path.join(MUSIC_DIR, filename)) del votes[vote_key] save_votes(votes) return jsonify({'status': 'deleted'}) except: pass save_votes(votes) return jsonify({'votes': votes[vote_key]}) # Monitor for ZIM directory changes last_zim_check = 0 def check_zim_updates(): global last_zim_check current_time = time.time() if current_time - last_zim_check > 300: # Check every 5 minutes last_zim_check = current_time try: # Check if ZIM directory has been modified zim_mtime = os.path.getmtime(ZIM_DIR) if zim_mtime > last_zim_check - 300: restart_kiwix_server() except: pass @app.before_request def before_request(): check_zim_updates() if __name__ == '__main__': # Start kiwix-serve on startup 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)