glo/routes/attachments.py
2026-11-03 00:22:48 +00:00

126 lines
4.1 KiB
Python

from .common import (
ALLOWED_ATTACHMENT_EXTENSIONS,
ALLOWED_ATTACHMENT_MIME_TYPES,
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENT_NAME_LENGTH,
UPLOAD_DIR,
now_iso,
csrf_valid,
rate_limited,
require_user,
require_writable_user,
audit,
safe_json,
jsonify,
request,
db_write,
can_access_room,
can_access_channel,
get_channel_by_id,
)
from attachments import send_attachment_file
def register_attachment_routes(app):
@app.route("/api/attachments", methods=["POST"])
def upload_attachment():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
limited = rate_limited("attachment")
if limited:
return limited
app.maybe_cleanup_orphaned_attachments()
uploaded = request.files.get("attachment")
if not uploaded or not uploaded.filename:
return jsonify({"error": "Choose a file to upload."}), 400
original_name = app.secure_filename(uploaded.filename)[:MAX_ATTACHMENT_NAME_LENGTH]
if not original_name or "." not in original_name:
return jsonify({"error": "Attachment must have an allowed file extension."}), 400
extension = original_name.rsplit(".", 1)[1].lower()
if extension not in ALLOWED_ATTACHMENT_EXTENSIONS:
return jsonify({"error": "This file type is not allowed."}), 400
declared_mime = (uploaded.mimetype or "").lower()
guessed_mime = app.mimetypes.guess_type(original_name)[0] or ""
if declared_mime and declared_mime not in ALLOWED_ATTACHMENT_MIME_TYPES:
return jsonify({"error": "This file MIME type is not allowed."}), 400
if guessed_mime and guessed_mime not in ALLOWED_ATTACHMENT_MIME_TYPES:
return jsonify({"error": "This file extension is not allowed."}), 400
content = uploaded.stream.read(MAX_ATTACHMENT_BYTES + 1)
if not content:
return jsonify({"error": "Attachment cannot be empty."}), 400
if len(content) > MAX_ATTACHMENT_BYTES:
return jsonify({"error": "Attachments must be 100 MB or smaller."}), 400
stored_name = f"{app.secrets.token_urlsafe(24)}.{extension}"
destination = UPLOAD_DIR / stored_name
try:
destination.write_bytes(content)
app.os.chmod(destination, 0o600)
except OSError:
app.logger.exception("Could not save attachment.")
return jsonify({"error": "Could not save attachment."}), 500
mime_type = declared_mime or guessed_mime or "application/octet-stream"
try:
cursor = db_write(
"""
INSERT INTO attachments (
uploader_user_id,
stored_name,
original_name,
mime_type,
size_bytes,
created_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(user["id"], stored_name, original_name, mime_type, len(content), now_iso()),
)
except Exception:
destination.unlink(missing_ok=True)
raise
audit("attachment_uploaded", user["id"], "attachment", str(cursor.lastrowid), original_name)
return jsonify(
{
"attachment": {
"id": cursor.lastrowid,
"original_name": original_name,
"mime_type": mime_type,
"size_bytes": len(content),
"url": f"/api/attachments/{cursor.lastrowid}/download",
}
}
), 201
@app.route("/api/attachments/<int:attachment_id>/download")
def download_attachment(attachment_id: int):
user, error = require_user()
if error:
return error
return send_attachment_file(
UPLOAD_DIR,
attachment_id,
user,
can_access_room,
can_access_channel,
get_channel_by_id,
)