126 lines
4.1 KiB
Python
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,
|
|
)
|