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//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, )