idk how, but somehow i had pasted the code twice and it was still working???

This commit is contained in:
2025-10-10 20:52:28 +00:00
parent ce443e7d2e
commit d4197679cb

View File

@@ -558,580 +558,6 @@ fetchMessages();
</body></html> </body></html>
""" """
@app.context_processor
def inject_globals():
return dict(base_css=BASE_CSS, max_mb=MAX_UPLOAD_MB)
# -------------------------
# Startup
# -------------------------
if __name__ == "__main__":
# Initialize DB and ensure schema
with app.app_context():
init_db()
ensure_schema()
# Run app
app.run(host="0.0.0.0", port=5000, debug=True)
#!/usr/bin/env python3
"""
chatter.py — Minimalist Flask chat with secure login, file uploads, previews (including ZIP contents),
and auto-clear after 8 minutes of inactivity.
Features implemented:
- Authentication (register/login) with werkzeug password hashing
- Text messages + attachments stored in SQLite (messages.attachment holds filename)
- All file types allowed for upload; max upload size ~9 MB (slightly > Discord free)
- Attachments saved into uploads/ with timestamp-prefixed safe filenames
- "Attachment: <filename>, preview" UI (preview opens in new tab) and download link
- Preview supports images, video, gif, html (served directly), and zip (lists zip contents;
clicking a file inside zip streams/downloads that member file)
- Auto-migration to add `attachment` column if missing
- Auto-clear: when there's no activity for INACTIVITY_TIMEOUT (8 minutes), all messages
and attachment files are deleted
- Poll-based client updates (1.5s)
- Minimalist grey/monospace styling (keeps bold/italic/size emphasis)
"""
from flask import (
Flask, g, render_template_string, request, redirect, url_for, session, jsonify,
send_from_directory, abort, Response
)
import sqlite3
import os
import zipfile
import io
import mimetypes
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
from functools import wraps
from datetime import datetime, timedelta
import threading
import time
import urllib.parse
# -------------------------
# Config
# -------------------------
DB_PATH = "chatter.db"
UPLOAD_FOLDER = "uploads"
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
# a little more than Discord free limit (8 MB) — choose 9 MB
MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "9"))
MAX_CONTENT_LENGTH = MAX_UPLOAD_MB * 1024 * 1024
# inactivity timeout (seconds) — 8 minutes
INACTIVITY_TIMEOUT = 8 * 60
# preview extensions for direct preview UI (opens in new tab)
PREVIEW_EXTS = {"png", "jpg", "jpeg", "gif", "mp4", "webm", "html"}
ZIP_EXT = "zip"
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-key-change-this")
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# app.config['SESSION_COOKIE_SECURE'] = True # enable on TLS
# global last activity timestamp (epoch seconds)
_last_activity = time.time()
_last_activity_lock = threading.Lock()
# -------------------------
# Database helpers & migration
# -------------------------
def get_db():
db = getattr(g, "_database", None)
if db is None:
db = g._database = sqlite3.connect(DB_PATH, detect_types=sqlite3.PARSE_DECLTYPES)
db.row_factory = sqlite3.Row
return db
@app.teardown_appcontext
def close_connection(exc):
db = getattr(g, "_database", None)
if db is not None:
db.close()
def init_db():
db = get_db()
cur = db.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
text TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
db.commit()
ensure_schema()
def ensure_schema():
"""
Auto-migrate: if messages table missing 'attachment' column, add it.
Called after init_db() and safe to run multiple times.
"""
db = get_db()
cur = db.cursor()
cur.execute("PRAGMA table_info(messages)")
cols = [r["name"] for r in cur.fetchall()]
if "attachment" not in cols:
cur.execute("ALTER TABLE messages ADD COLUMN attachment TEXT")
db.commit()
# -------------------------
# Activity / cleanup
# -------------------------
def update_activity():
global _last_activity
with _last_activity_lock:
_last_activity = time.time()
def prune_all_messages_and_files():
"""Delete all rows from messages and all files in uploads/ safely."""
db = get_db()
cur = db.cursor()
cur.execute("SELECT attachment FROM messages WHERE attachment IS NOT NULL")
rows = cur.fetchall()
for r in rows:
fn = r["attachment"]
if fn:
path = os.path.join(UPLOAD_FOLDER, fn)
try:
if os.path.exists(path):
os.remove(path)
except Exception:
pass
cur.execute("DELETE FROM messages")
db.commit()
def inactivity_watcher():
"""Background thread that watches for inactivity and clears everything after timeout."""
global _last_activity
while True:
time.sleep(5)
with _last_activity_lock:
idle = time.time() - _last_activity
if idle >= INACTIVITY_TIMEOUT:
# perform cleanup in app context
try:
with app.app_context():
prune_all_messages_and_files()
except Exception:
pass
# reset last_activity to avoid repeated work
with _last_activity_lock:
_last_activity = time.time()
# start watcher thread (daemon)
threading.Thread(target=inactivity_watcher, daemon=True).start()
# -------------------------
# Auth decorator
# -------------------------
def login_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
if "user_id" not in session:
return redirect(url_for("login"))
return f(*args, **kwargs)
return wrapper
# -------------------------
# Helpers
# -------------------------
def allowed_preview_ext(filename: str) -> bool:
fe = filename.rsplit(".", 1)[-1].lower()
return fe in PREVIEW_EXTS
def is_zip(filename: str) -> bool:
return filename.rsplit(".", 1)[-1].lower() == ZIP_EXT
def safe_save_file(storage_file):
"""
Save uploaded FileStorage to uploads/ with a timestamp prefix to keep names unique.
Returns saved filename (basename).
"""
orig = storage_file.filename
secured = secure_filename(orig)
prefix = datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
saved = f"{prefix}_{secured}"
path = os.path.join(UPLOAD_FOLDER, saved)
storage_file.save(path)
return saved
# -------------------------
# Routes
# -------------------------
@app.route("/")
def index():
if "user_id" in session:
return redirect(url_for("chat"))
return redirect(url_for("login"))
@app.route("/register", methods=["GET", "POST"])
def register():
error = None
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
if not username or not password:
error = "Provide username and password"
else:
db = get_db()
cur = db.cursor()
try:
cur.execute(
"INSERT INTO users (username,password_hash) VALUES (?,?)",
(username, generate_password_hash(password)),
)
db.commit()
return redirect(url_for("login"))
except sqlite3.IntegrityError:
error = "Username already taken"
return render_template_string(REGISTER_HTML, error=error)
@app.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
db = get_db()
cur = db.cursor()
cur.execute("SELECT id, username, password_hash FROM users WHERE username=?", (username,))
row = cur.fetchone()
if row and check_password_hash(row["password_hash"], password):
session.clear()
session["user_id"] = row["id"]
session["username"] = row["username"]
update_activity()
return redirect(url_for("chat"))
else:
error = "Invalid username or password"
return render_template_string(LOGIN_HTML, error=error)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.route("/chat")
@login_required
def chat():
update_activity()
return render_template_string(CHAT_HTML, username=session.get("username"), max_mb=MAX_UPLOAD_MB)
@app.route("/api/messages", methods=["GET", "POST"])
@login_required
def api_messages():
update_activity()
db = get_db()
cur = db.cursor()
if request.method == "POST":
# Accept multipart/form-data (file) or JSON
text = ""
attachment_saved = None
if request.content_type and request.content_type.startswith("multipart/form-data"):
text = (request.form.get("text") or "").strip()
file = request.files.get("file")
if file and file.filename:
attachment_saved = safe_save_file(file)
else:
data = request.get_json(silent=True) or {}
text = (data.get("text") or "").strip()
if not text and not attachment_saved:
return jsonify({"error": "Empty message"}), 400
cur.execute(
"INSERT INTO messages (user_id, username, text, attachment, created_at) VALUES (?,?,?,?,?)",
(session["user_id"], session["username"], text, attachment_saved, datetime.utcnow()),
)
db.commit()
return jsonify({"ok": True}), 201
# GET -> return last 100 messages (ascending)
cur.execute("SELECT id, username, text, attachment, created_at FROM messages ORDER BY id DESC LIMIT 100")
rows = cur.fetchall()
msgs = [
{
"id": r["id"],
"username": r["username"],
"text": r["text"],
"attachment": r["attachment"],
"created_at": r["created_at"],
}
for r in reversed(rows)
]
return jsonify(msgs)
@app.route("/uploads/<path:filename>")
def uploaded_file(filename):
# Download directly as attachment
# filename is the saved basename (timestamp_prefix_originalname.ext)
return send_from_directory(UPLOAD_FOLDER, filename, as_attachment=True)
@app.route("/preview/<path:filename>")
def preview(filename):
"""
Preview endpoint:
- For images/videos/gifs/html -> serve file directly (Content-Type auto)
- For zip -> return an HTML listing of members with links to stream/download:
/preview/zip/<filename>/<path:member>
- For other files -> redirect to download
"""
safe_name = filename # we expect saved basenames; still be cautious
fpath = os.path.join(UPLOAD_FOLDER, safe_name)
if not os.path.exists(fpath):
abort(404)
ext = safe_name.rsplit(".", 1)[-1].lower()
if ext in PREVIEW_EXTS and ext != "html":
# images/videos/gif/mp4/webm -> serve raw file (browser will open in new tab)
return send_from_directory(UPLOAD_FOLDER, safe_name)
if ext == "html":
# HTML: serve so browser renders it. But to prevent accidental direct script execution,
# we will serve it with correct mimetype; it's the user's file.
return send_from_directory(UPLOAD_FOLDER, safe_name)
if ext == ZIP_EXT:
# list zip contents and provide links to preview/download each member
try:
with zipfile.ZipFile(fpath, "r") as zf:
members = zf.namelist()
except zipfile.BadZipFile:
return "<h3>Bad ZIP file</h3>", 400
# Build a simple listing HTML; links route to /preview/zipfile/<filename>/<encoded_member>
rows = []
for m in members:
# encode member path for URL (use urllib.parse.quote)
enc = urllib.parse.quote(m, safe="")
# Provide a link that streams the inner file (served from zip)
rows.append(f'<li><a href="/preview/zipfile/{urllib.parse.quote(safe_name)}/{enc}" target="_blank" rel="noopener">{m}</a></li>')
html = f"""
<!doctype html>
<html><head><meta charset="utf-8"><title>Contents of {safe_name}</title></head>
<body style="font-family:Courier,monospace;background:#fff;color:#111;padding:18px;">
<h3>Contents of {safe_name}</h3>
<ul>
{''.join(rows)}
</ul>
</body></html>
"""
return html
# fallback -> direct download
return redirect(url_for("uploaded_file", filename=safe_name))
@app.route("/preview/zipfile/<path:zipname>/<path:member>")
def preview_zip_member(zipname, member):
"""
Stream a file inside a zip directly without extracting to disk.
Sets Content-Disposition to attachment so the browser offers download (or opens inline if it can).
"""
zipname_safe = zipname
zip_path = os.path.join(UPLOAD_FOLDER, zipname_safe)
if not os.path.exists(zip_path):
abort(404)
# URL-decoding of member path already performed by Flask; member contains decoded path
member_name = member
try:
with zipfile.ZipFile(zip_path, "r") as zf:
# ensure the member exists
if member_name not in zf.namelist():
# try some normalization (zip can have backslashes on Windows)
norm_match = None
for nm in zf.namelist():
if nm.replace("\\", "/") == member_name.replace("\\", "/"):
norm_match = nm
break
if norm_match:
member_name = norm_match
else:
abort(404)
with zf.open(member_name) as member_file:
data = member_file.read()
except zipfile.BadZipFile:
abort(400, "Bad ZIP file")
except KeyError:
abort(404)
# Guess mime type for the inner file
guessed_type, _ = mimetypes.guess_type(member_name)
if not guessed_type:
guessed_type = "application/octet-stream"
# Return as file-like response
rv = Response(data, mimetype=guessed_type)
# Present as attachment with original inner filename
inner_basename = os.path.basename(member_name)
rv.headers["Content-Disposition"] = f'attachment; filename="{inner_basename}"'
return rv
# -------------------------
# Minimal HTML templates
# -------------------------
BASE_CSS = """
:root{--bg:#ececec;--card:#f8f8f8;--muted:#666}
html,body{height:100%}
body{margin:0;background:var(--bg);font-family:"Courier New",Courier,monospace;color:#111}
.container{max-width:900px;margin:24px auto;padding:18px;background:var(--card);border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,0.04)}
header{margin-bottom:18px}
h1{font-size:28px;margin:0}
small{color:var(--muted)}
.chat{height:60vh;border:1px dashed #ddd;padding:12px;overflow:auto;background:white}
.message{padding:6px 8px;border-bottom:1px dashed #efefef}
.username{font-weight:700}
.time{font-style:italic;color:var(--muted);font-size:12px}
.attachment{font-style:italic;color:var(--muted);font-size:13px;margin-top:6px}
.form-row{display:flex;gap:8px;margin-top:12px}
input[type=text],input[type=password]{font-family:inherit;padding:8px;border:1px solid #ddd;border-radius:6px;flex:1}
button{padding:8px 12px;border-radius:6px;border:0;background:#222;color:white;font-weight:700}
.error{color:#a00;margin-top:8px}
.note{font-size:14px;color:var(--muted)}
.file-input{border:1px solid #ddd;padding:6px;border-radius:6px}
@media(max-width:600px){.container{margin:8px;padding:12px}}
"""
LOGIN_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"><title>chatter — login</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>{{ base_css }}</style></head>
<body>
<div class="container">
<header><h1><span style="font-size:20px;font-weight:700">chatter</span></h1><div class="note">Minimal chat — <em>monospace, grey</em></div></header>
<form method="post" autocomplete="off">
<div style="display:flex;flex-direction:column;gap:8px;max-width:420px">
<label><strong>Username</strong></label><input name="username" required>
<label><strong>Password</strong></label><input type="password" name="password" required>
<div style="display:flex;gap:8px;margin-top:6px"><button type="submit">Log in</button><a href="/register" style="align-self:center;color:var(--muted);text-decoration:underline">Create account</a></div>
</div></form>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
</div></body></html>
"""
REGISTER_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"><title>chatter — register</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>{{ base_css }}</style></head>
<body>
<div class="container">
<header><h1><span style="font-size:20px;font-weight:700">chatter</span></h1><div class="note">Create account — <em>minimal</em></div></header>
<form method="post" autocomplete="off">
<div style="display:flex;flex-direction:column;gap:8px;max-width:420px">
<label><strong>Username</strong></label><input name="username" required>
<label><strong>Password</strong></label><input type="password" name="password" required>
<div style="display:flex;gap:8px;margin-top:6px"><button type="submit">Register</button><a href="/login" style="align-self:center;color:var(--muted);text-decoration:underline">Back to login</a></div>
</div></form>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
</div></body></html>
"""
CHAT_HTML = """
<!doctype html>
<html><head><meta charset="utf-8"><title>chatter</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>{{ base_css }}</style></head>
<body>
<div class="container">
<header><h1><span style="font-size:22px;font-weight:700">chatter</span> <small>— minimal</small></h1>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px"><div class="note">Logged in as <span class="username">{{ username }}</span></div><div><a href="/logout" style="color:var(--muted);text-decoration:underline">Log out</a></div></div></header>
<div id="chat" class="chat" aria-live="polite"></div>
<div style="margin-top:8px">
<form id="sendForm" onsubmit="return sendMessage();" enctype="multipart/form-data">
<div class="form-row">
<input id="textInput" type="text" placeholder="Type a message..." autocomplete="off">
<input id="fileInput" class="file-input" type="file">
<button type="submit">Send</button>
</div>
</form>
</div>
<div style="margin-top:12px;color:var(--muted);font-size:13px">Files allowed (all types). Size limit: {{ max_mb }} MB. Attachments show: <em>Attachment: filename, preview</em></div>
</div>
<script>
const chatEl = document.getElementById('chat');
let lastMessages = [];
async function fetchMessages(){
try{
const res = await fetch('/api/messages');
if(!res.ok) throw new Error('fetch failed');
const msgs = await res.json();
renderMessages(msgs);
}catch(e){}
}
function renderMessages(msgs){
const serialized = JSON.stringify(msgs);
if(serialized === JSON.stringify(lastMessages)) return;
lastMessages = msgs;
chatEl.innerHTML = '';
msgs.forEach(m=>{
const d = document.createElement('div');
d.className = 'message';
const time = new Date(m.created_at).toLocaleString();
let attachmentHtml = '';
if(m.attachment){
const downloadUrl = '/uploads/' + encodeURIComponent(m.attachment);
// preview link only for images/videos/html/zip (zip handled on server)
const ext = (m.attachment.split('.').pop() || '').toLowerCase();
let previewPart = '';
if(['png','jpg','jpeg','gif','mp4','webm','html','zip'].includes(ext)){
const previewUrl = '/preview/' + encodeURIComponent(m.attachment);
previewPart = '<a href="'+previewUrl+'" target="_blank" rel="noopener">preview</a>';
}
attachmentHtml = '<div class="attachment">Attachment: '+ m.attachment +', <a href="'+downloadUrl+'">download</a>' + (previewPart ? (', ' + previewPart) : '') + '</div>';
}
d.innerHTML = '<div><span class="username">'+escapeHtml(m.username)+'</span> <span class="time">'+escapeHtml(time)+'</span></div>'
+ '<div style="margin-top:4px">'+escapeHtml(m.text || '')+'</div>'
+ attachmentHtml;
chatEl.appendChild(d);
});
chatEl.scrollTop = chatEl.scrollHeight;
}
function escapeHtml(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
async function sendMessage(){
const input = document.getElementById('textInput');
const fileInput = document.getElementById('fileInput');
const text = input.value.trim();
const file = fileInput.files[0];
if(!text && !file) return false;
try{
if(file){
const form = new FormData();
form.append('text', text);
form.append('file', file);
await fetch('/api/messages', { method: 'POST', body: form });
} else {
await fetch('/api/messages', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({text}) });
}
input.value = ''; fileInput.value = '';
fetchMessages();
}catch(e){ console.error(e) }
return false;
}
setInterval(fetchMessages, 1500);
fetchMessages();
</script>
</body></html>
"""
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
return dict(base_css=BASE_CSS, max_mb=MAX_UPLOAD_MB) return dict(base_css=BASE_CSS, max_mb=MAX_UPLOAD_MB)