feat: add remote URL attachment support and project editing endpoint
This commit is contained in:
@@ -1,14 +1,25 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import httpx
|
||||
from devplacepy.database import get_table, db, get_setting
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REMOTE_FETCH_TIMEOUT = 20.0
|
||||
REMOTE_FETCH_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
UPLOADS_DIR = STATIC_DIR / "uploads"
|
||||
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
|
||||
THUMBNAIL_SIZE = (200, 200)
|
||||
@@ -40,6 +51,25 @@ ALLOWED_UPLOAD_TYPES = {
|
||||
".md": "text/markdown",
|
||||
}
|
||||
|
||||
MIME_TO_EXT = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp",
|
||||
"image/tiff": ".tiff",
|
||||
"application/pdf": ".pdf",
|
||||
"application/zip": ".zip",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"video/ogg": ".ogv",
|
||||
"video/quicktime": ".mov",
|
||||
"video/x-m4v": ".m4v",
|
||||
"audio/mpeg": ".mp3",
|
||||
"text/plain": ".txt",
|
||||
"text/markdown": ".md",
|
||||
}
|
||||
|
||||
FILE_ICONS = {
|
||||
".pdf": "\U0001f4c4",
|
||||
".zip": "\U0001f4e6",
|
||||
@@ -206,6 +236,107 @@ def store_attachment(file_bytes, original_filename, user_uid):
|
||||
}
|
||||
|
||||
|
||||
class RemoteFetchError(Exception):
|
||||
def __init__(self, message, status=400):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
async def _guard_public_url(url):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise RemoteFetchError("Only http and https URLs can be attached.", 400)
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise RemoteFetchError("The URL has no host.", 400)
|
||||
try:
|
||||
infos = await asyncio.to_thread(socket.getaddrinfo, host, None)
|
||||
except socket.gaierror as exc:
|
||||
raise RemoteFetchError(f"Could not resolve host: {host}", 400) from exc
|
||||
for info in infos:
|
||||
address = ipaddress.ip_address(info[4][0])
|
||||
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
||||
address = address.ipv4_mapped
|
||||
if (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_reserved
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
):
|
||||
raise RemoteFetchError(
|
||||
f"Refusing to attach a private or local address ({address}).", 400
|
||||
)
|
||||
|
||||
|
||||
def _resolve_remote_filename(final_url, content_type, override):
|
||||
name = (override or "").strip() or Path(urlparse(final_url).path).name
|
||||
ext = Path(name).suffix.lower()
|
||||
if name and ext and is_extension_allowed(ext):
|
||||
return name
|
||||
base_mime = (content_type or "").split(";")[0].strip().lower()
|
||||
mapped = MIME_TO_EXT.get(base_mime)
|
||||
if mapped is None or not is_extension_allowed(mapped):
|
||||
return None
|
||||
stem = Path(name).stem or "download"
|
||||
return f"{stem}{mapped}"
|
||||
|
||||
|
||||
async def fetch_remote_file(url, filename=None):
|
||||
if "://" not in url:
|
||||
url = "https://" + url
|
||||
await _guard_public_url(url)
|
||||
max_bytes = _get_max_upload_bytes()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=REMOTE_FETCH_TIMEOUT,
|
||||
headers={"User-Agent": REMOTE_FETCH_USER_AGENT},
|
||||
) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
if response.status_code >= 400:
|
||||
raise RemoteFetchError(
|
||||
f"The remote server returned {response.status_code}.", 400
|
||||
)
|
||||
final_url = str(response.url)
|
||||
content_type = response.headers.get("content-type", "")
|
||||
chunks = []
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise RemoteFetchError(
|
||||
f"The file exceeds the {max_bytes // (1024 * 1024)}MB limit.",
|
||||
413,
|
||||
)
|
||||
data = b"".join(chunks)
|
||||
except httpx.HTTPError as exc:
|
||||
raise RemoteFetchError(f"Could not fetch {url}: {exc}", 400) from exc
|
||||
|
||||
name = _resolve_remote_filename(final_url, content_type, filename)
|
||||
if name is None:
|
||||
raise RemoteFetchError(
|
||||
"Could not determine an allowed file type for the URL. Pass a filename "
|
||||
"with an allowed extension.",
|
||||
415,
|
||||
)
|
||||
return name, data
|
||||
|
||||
|
||||
async def store_attachment_from_url(url, user_uid, filename=None):
|
||||
name, data = await fetch_remote_file(url, filename)
|
||||
result = store_attachment(data, name, user_uid)
|
||||
if result is None:
|
||||
raise RemoteFetchError(
|
||||
"The downloaded file is not an allowed type or exceeds the size limit.",
|
||||
413,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def link_attachments(uids, target_type, target_uid):
|
||||
flat = [
|
||||
uid.strip() for raw in uids or [] for uid in str(raw).split(",") if uid.strip()
|
||||
|
||||
Reference in New Issue
Block a user