336 lines
9.4 KiB
Python
336 lines
9.4 KiB
Python
from datetime import timedelta
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from flask import abort, jsonify, send_from_directory
|
|
|
|
from db import get_db
|
|
|
|
|
|
IMAGE_MIME_PREFIX = "image/"
|
|
AUDIO_MIME_PREFIX = "audio/"
|
|
VIDEO_MIME_PREFIX = "video/"
|
|
|
|
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
|
AUDIO_EXTENSIONS = {"mp3", "wav", "ogg", "m4a", "aac", "flac"}
|
|
VIDEO_EXTENSIONS = {"mp4", "webm", "mov", "m4v", "ogv"}
|
|
|
|
|
|
def attachment_kind_from_name_and_mime(
|
|
original_name: Optional[str],
|
|
mime_type: Optional[str],
|
|
) -> str:
|
|
mime_type = str(mime_type or "").lower()
|
|
name = str(original_name or "")
|
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS:
|
|
return "image"
|
|
if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS:
|
|
return "audio"
|
|
if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS:
|
|
return "video"
|
|
return "file"
|
|
|
|
|
|
def attachment_payload_from_row(row) -> Optional[dict]:
|
|
if not row or not row["id"]:
|
|
return None
|
|
kind = attachment_kind_from_name_and_mime(
|
|
row["original_name"],
|
|
row["mime_type"],
|
|
)
|
|
return {
|
|
"id": row["id"],
|
|
"original_name": row["original_name"],
|
|
"mime_type": row["mime_type"],
|
|
"size_bytes": row["size_bytes"],
|
|
"kind": kind,
|
|
"url": f"/api/attachments/{row['id']}/download",
|
|
}
|
|
|
|
|
|
def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]:
|
|
if not row["attachment_id"]:
|
|
return None
|
|
kind = attachment_kind_from_name_and_mime(
|
|
row["attachment_original_name"],
|
|
row["attachment_mime_type"],
|
|
)
|
|
return {
|
|
"id": row["attachment_id"],
|
|
"original_name": row["attachment_original_name"],
|
|
"mime_type": row["attachment_mime_type"],
|
|
"size_bytes": row["attachment_size_bytes"],
|
|
"kind": kind,
|
|
"url": f"/api/attachments/{row['attachment_id']}/download",
|
|
}
|
|
|
|
|
|
def attachment_list_for_message(message_id: int) -> list[dict]:
|
|
with get_db() as db:
|
|
rows = db.execute(
|
|
"""
|
|
SELECT
|
|
a.id,
|
|
a.original_name,
|
|
a.mime_type,
|
|
a.size_bytes
|
|
FROM message_attachments AS ma
|
|
JOIN attachments AS a
|
|
ON a.id = ma.attachment_id
|
|
WHERE ma.message_id = ?
|
|
ORDER BY ma.position ASC, a.id ASC
|
|
""",
|
|
(message_id,),
|
|
).fetchall()
|
|
return [attachment_payload_from_row(row) for row in rows]
|
|
|
|
|
|
def cleanup_orphaned_attachments(
|
|
upload_dir: Path,
|
|
utc_now_func,
|
|
orphan_ttl_hours: int,
|
|
app_logger,
|
|
) -> None:
|
|
cutoff = (utc_now_func() - timedelta(hours=orphan_ttl_hours)).isoformat()
|
|
with get_db() as db:
|
|
orphaned = db.execute(
|
|
"""
|
|
SELECT
|
|
a.id,
|
|
a.stored_name
|
|
FROM attachments AS a
|
|
WHERE a.created_at < ?
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM messages AS m
|
|
WHERE m.attachment_id = a.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM message_attachments AS ma
|
|
WHERE ma.attachment_id = a.id
|
|
)
|
|
""",
|
|
(cutoff,),
|
|
).fetchall()
|
|
for attachment in orphaned:
|
|
db.execute(
|
|
"""
|
|
DELETE FROM attachments
|
|
WHERE id = ?
|
|
""",
|
|
(attachment["id"],),
|
|
)
|
|
db.commit()
|
|
|
|
for attachment in orphaned:
|
|
try:
|
|
(upload_dir / attachment["stored_name"]).unlink(missing_ok=True)
|
|
except OSError:
|
|
app_logger.warning(
|
|
"Could not remove orphaned attachment file: %s",
|
|
attachment["stored_name"],
|
|
)
|
|
|
|
|
|
def maybe_cleanup_orphaned_attachments(
|
|
state: dict,
|
|
cleanup_interval_seconds: int,
|
|
cleanup_func,
|
|
app_logger,
|
|
) -> None:
|
|
import time
|
|
|
|
now_value = time.time()
|
|
last = state.get("last_attachment_cleanup", 0.0)
|
|
if now_value - last < cleanup_interval_seconds:
|
|
return
|
|
state["last_attachment_cleanup"] = now_value
|
|
try:
|
|
cleanup_func()
|
|
except Exception:
|
|
app_logger.exception("Could not clean orphaned attachments.")
|
|
|
|
|
|
def resolve_owned_attachment_ids(
|
|
attachment_ids: object,
|
|
user_id: int,
|
|
) -> tuple[Optional[list[int]], Optional[tuple]]:
|
|
if attachment_ids is None:
|
|
return [], None
|
|
if not isinstance(attachment_ids, list):
|
|
return None, (jsonify({"error": "attachment_ids must be an array."}), 400)
|
|
|
|
parsed = []
|
|
seen = set()
|
|
for item in attachment_ids[:10]:
|
|
try:
|
|
attachment_id = int(item)
|
|
except (TypeError, ValueError):
|
|
return None, (jsonify({"error": "Invalid attachment."}), 400)
|
|
if attachment_id in seen:
|
|
continue
|
|
seen.add(attachment_id)
|
|
with get_db() as db:
|
|
row = db.execute(
|
|
"""
|
|
SELECT id, uploader_user_id
|
|
FROM attachments
|
|
WHERE id = ?
|
|
""",
|
|
(attachment_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return None, (jsonify({"error": "Attachment not found."}), 404)
|
|
if row["uploader_user_id"] != user_id:
|
|
return None, (
|
|
jsonify({"error": "You can only attach files you uploaded."}),
|
|
403,
|
|
)
|
|
parsed.append(attachment_id)
|
|
return parsed, None
|
|
|
|
|
|
def resolve_owned_attachment(
|
|
attachment_id: object,
|
|
user_id: int,
|
|
) -> tuple[Optional[int], Optional[tuple]]:
|
|
if attachment_id is None:
|
|
return None, None
|
|
try:
|
|
parsed_id = int(attachment_id)
|
|
except (TypeError, ValueError):
|
|
return None, (jsonify({"error": "Invalid attachment."}), 400)
|
|
|
|
with get_db() as db:
|
|
attachment = db.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
uploader_user_id
|
|
FROM attachments
|
|
WHERE id = ?
|
|
""",
|
|
(parsed_id,),
|
|
).fetchone()
|
|
if not attachment:
|
|
return None, (jsonify({"error": "Attachment not found."}), 404)
|
|
if attachment["uploader_user_id"] != user_id:
|
|
return None, (
|
|
jsonify({"error": "You can only attach files you uploaded."}),
|
|
403,
|
|
)
|
|
return parsed_id, None
|
|
|
|
|
|
def can_download_attachment(
|
|
attachment_id: int,
|
|
user: Optional[dict],
|
|
can_access_room,
|
|
can_access_channel,
|
|
get_channel_by_id,
|
|
) -> bool:
|
|
if not user:
|
|
return False
|
|
with get_db() as db:
|
|
room_rows = db.execute(
|
|
"""
|
|
SELECT DISTINCT
|
|
r.id,
|
|
r.slug,
|
|
r.name,
|
|
r.description,
|
|
r.visibility,
|
|
r.created_by_user_id,
|
|
r.is_active
|
|
FROM message_attachments AS ma
|
|
JOIN messages AS m
|
|
ON m.id = ma.message_id
|
|
JOIN rooms AS r
|
|
ON r.id = m.room_id
|
|
WHERE ma.attachment_id = ?
|
|
AND r.is_active = 1
|
|
""",
|
|
(attachment_id,),
|
|
).fetchall()
|
|
channel_rows = db.execute(
|
|
"""
|
|
SELECT DISTINCT
|
|
m.channel_id
|
|
FROM message_attachments AS ma
|
|
JOIN messages AS m
|
|
ON m.id = ma.message_id
|
|
WHERE ma.attachment_id = ?
|
|
AND m.channel_id IS NOT NULL
|
|
""",
|
|
(attachment_id,),
|
|
).fetchall()
|
|
dm_rows = db.execute(
|
|
"""
|
|
SELECT DISTINCT dc.id
|
|
FROM message_attachments AS ma
|
|
JOIN messages AS m
|
|
ON m.id = ma.message_id
|
|
JOIN direct_conversations AS dc
|
|
ON dc.id = m.direct_conversation_id
|
|
JOIN direct_conversation_members AS dcm
|
|
ON dcm.direct_conversation_id = dc.id
|
|
WHERE ma.attachment_id = ?
|
|
AND dcm.user_id = ?
|
|
""",
|
|
(attachment_id, user["id"]),
|
|
).fetchall()
|
|
|
|
if dm_rows:
|
|
return True
|
|
if any(can_access_room(room, user) for room in room_rows):
|
|
return True
|
|
for item in channel_rows:
|
|
channel = get_channel_by_id(item["channel_id"])
|
|
if channel and can_access_channel(channel, user):
|
|
return True
|
|
return False
|
|
|
|
|
|
def send_attachment_file(
|
|
upload_dir: Path,
|
|
attachment_id: int,
|
|
user: Optional[dict],
|
|
can_access_room,
|
|
can_access_channel,
|
|
get_channel_by_id,
|
|
):
|
|
with get_db() as db:
|
|
attachment = db.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
stored_name,
|
|
original_name,
|
|
mime_type
|
|
FROM attachments
|
|
WHERE id = ?
|
|
""",
|
|
(attachment_id,),
|
|
).fetchone()
|
|
if not attachment:
|
|
abort(404)
|
|
|
|
if not can_download_attachment(
|
|
attachment_id,
|
|
user,
|
|
can_access_room,
|
|
can_access_channel,
|
|
get_channel_by_id,
|
|
):
|
|
abort(403)
|
|
|
|
return send_from_directory(
|
|
upload_dir,
|
|
attachment["stored_name"],
|
|
as_attachment=True,
|
|
download_name=attachment["original_name"],
|
|
mimetype=attachment["mime_type"],
|
|
)
|