#!/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 %}
{% endfor %}
📚 Knowledge Library (ZIM Files)
{% if kiwix_available %}
{% if zim_files %}
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)