#!/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 %}
📸 Photo Gallery
⬇️ Downvote photos to delete them (3 votes = deletion)
{% for photo in photos[:6] %}
{% 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] %}
{% 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 }}
{% 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)