Attachments support for the outbox
This commit is contained in:
10
app/admin.py
10
app/admin.py
@@ -22,6 +22,7 @@ from app.config import verify_csrf_token
|
||||
from app.config import verify_password
|
||||
from app.database import get_db
|
||||
from app.lookup import lookup
|
||||
from app.uploads import save_upload
|
||||
|
||||
|
||||
def user_session_or_redirect(
|
||||
@@ -231,7 +232,7 @@ def admin_actions_bookmark(
|
||||
|
||||
|
||||
@router.post("/actions/new")
|
||||
async def admin_actions_new(
|
||||
def admin_actions_new(
|
||||
request: Request,
|
||||
files: list[UploadFile],
|
||||
content: str = Form(),
|
||||
@@ -240,9 +241,12 @@ async def admin_actions_new(
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
# XXX: for some reason, no files restuls in an empty single file
|
||||
uploads = []
|
||||
if len(files) >= 1 and files[0].filename:
|
||||
print("Got files")
|
||||
public_id = boxes.send_create(db, content)
|
||||
for f in files:
|
||||
upload = save_upload(db, f)
|
||||
uploads.append((upload, f.filename))
|
||||
public_id = boxes.send_create(db, source=content, uploads=uploads)
|
||||
return RedirectResponse(
|
||||
request.url_for("outbox_by_public_id", public_id=public_id),
|
||||
status_code=302,
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -60,21 +61,35 @@ class Object:
|
||||
return self.ap_object.get("sensitive", False)
|
||||
|
||||
@property
|
||||
def attachments(self) -> list["Attachment"]:
|
||||
attachments = [
|
||||
Attachment.parse_obj(obj) for obj in self.ap_object.get("attachment", [])
|
||||
]
|
||||
def attachments_old(self) -> list["Attachment"]:
|
||||
# TODO: set img_src with the proxy URL (proxy_url?)
|
||||
attachments = []
|
||||
for obj in self.ap_object.get("attachment", []):
|
||||
proxied_url = _proxied_url(obj["url"])
|
||||
attachments.append(
|
||||
Attachment.parse_obj(
|
||||
{
|
||||
"proxiedUrl": proxied_url,
|
||||
"resizedUrl": proxied_url + "/740"
|
||||
if obj["mediaType"].startswith("image")
|
||||
else None,
|
||||
**obj,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Also add any video Link (for PeerTube compat)
|
||||
if self.ap_type == "Video":
|
||||
for link in ap.as_list(self.ap_object.get("url", [])):
|
||||
if (isinstance(link, dict)) and link.get("type") == "Link":
|
||||
if link.get("mediaType", "").startswith("video"):
|
||||
proxied_url = _proxied_url(link["href"])
|
||||
attachments.append(
|
||||
Attachment(
|
||||
type="Video",
|
||||
mediaType=link["mediaType"],
|
||||
url=link["href"],
|
||||
proxiedUrl=proxied_url,
|
||||
)
|
||||
)
|
||||
break
|
||||
@@ -137,12 +152,20 @@ class BaseModel(pydantic.BaseModel):
|
||||
alias_generator = _to_camel
|
||||
|
||||
|
||||
def _proxied_url(url: str) -> str:
|
||||
return "/proxy/media/" + base64.urlsafe_b64encode(url.encode()).decode()
|
||||
|
||||
|
||||
class Attachment(BaseModel):
|
||||
type: str
|
||||
media_type: str
|
||||
name: str | None
|
||||
url: str
|
||||
|
||||
# Extra fields for the templates
|
||||
proxied_url: str
|
||||
resized_url: str | None = None
|
||||
|
||||
|
||||
class RemoteObject(Object):
|
||||
def __init__(self, raw_object: ap.RawObject, actor: Actor | None = None):
|
||||
|
20
app/boxes.py
20
app/boxes.py
@@ -22,6 +22,7 @@ from app.config import ID
|
||||
from app.database import now
|
||||
from app.process_outgoing_activities import new_outgoing_activity
|
||||
from app.source import markdownify
|
||||
from app.uploads import upload_to_attachment
|
||||
|
||||
|
||||
def allocate_outbox_id() -> str:
|
||||
@@ -214,11 +215,20 @@ def send_undo(db: Session, ap_object_id: str) -> None:
|
||||
raise ValueError("Should never happen")
|
||||
|
||||
|
||||
def send_create(db: Session, source: str) -> str:
|
||||
def send_create(
|
||||
db: Session,
|
||||
source: str,
|
||||
uploads: list[tuple[models.Upload, str]],
|
||||
) -> str:
|
||||
note_id = allocate_outbox_id()
|
||||
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
context = f"{ID}/contexts/" + uuid.uuid4().hex
|
||||
content, tags = markdownify(db, source)
|
||||
attachments = []
|
||||
|
||||
for (upload, filename) in uploads:
|
||||
attachments.append(upload_to_attachment(upload, filename))
|
||||
|
||||
note = {
|
||||
"@context": ap.AS_CTX,
|
||||
"type": "Note",
|
||||
@@ -235,6 +245,7 @@ def send_create(db: Session, source: str) -> str:
|
||||
"summary": None,
|
||||
"inReplyTo": None,
|
||||
"sensitive": False,
|
||||
"attachment": attachments,
|
||||
}
|
||||
outbox_object = save_outbox_object(db, note_id, note, source=source)
|
||||
if not outbox_object.id:
|
||||
@@ -247,6 +258,13 @@ def send_create(db: Session, source: str) -> str:
|
||||
outbox_object_id=outbox_object.id,
|
||||
)
|
||||
db.add(tagged_object)
|
||||
|
||||
for (upload, filename) in uploads:
|
||||
outbox_object_attachment = models.OutboxObjectAttachment(
|
||||
filename=filename, outbox_object_id=outbox_object.id, upload_id=upload.id
|
||||
)
|
||||
db.add(outbox_object_attachment)
|
||||
|
||||
db.commit()
|
||||
|
||||
recipients = _compute_recipients(db, note)
|
||||
|
122
app/main.py
122
app/main.py
@@ -3,6 +3,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from typing import Type
|
||||
|
||||
@@ -13,10 +14,12 @@ from fastapi import FastAPI
|
||||
from fastapi import Request
|
||||
from fastapi import Response
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import joinedload
|
||||
from starlette.background import BackgroundTask
|
||||
@@ -41,6 +44,7 @@ from app.config import USERNAME
|
||||
from app.config import is_activitypub_requested
|
||||
from app.database import get_db
|
||||
from app.templates import is_current_user_admin
|
||||
from app.uploads import UPLOAD_DIR
|
||||
|
||||
# TODO(ts):
|
||||
#
|
||||
@@ -113,6 +117,8 @@ async def add_security_headers(request: Request, call_next):
|
||||
response.headers["x-xss-protection"] = "1; mode=block"
|
||||
response.headers["x-frame-options"] = "SAMEORIGIN"
|
||||
# TODO(ts): disallow inline CSS?
|
||||
if DEBUG:
|
||||
return response
|
||||
response.headers["content-security-policy"] = (
|
||||
"default-src 'self'" + " style-src 'self' 'unsafe-inline';"
|
||||
)
|
||||
@@ -157,6 +163,11 @@ def index(
|
||||
|
||||
outbox_objects = (
|
||||
db.query(models.OutboxObject)
|
||||
.options(
|
||||
joinedload(models.OutboxObject.outbox_object_attachments).options(
|
||||
joinedload(models.OutboxObjectAttachment.upload)
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
@@ -367,6 +378,11 @@ def outbox_by_public_id(
|
||||
# TODO: ACL?
|
||||
maybe_object = (
|
||||
db.query(models.OutboxObject)
|
||||
.options(
|
||||
joinedload(models.OutboxObject.outbox_object_attachments).options(
|
||||
joinedload(models.OutboxObjectAttachment.upload)
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
models.OutboxObject.public_id == public_id,
|
||||
# models.OutboxObject.is_deleted.is_(False),
|
||||
@@ -550,6 +566,112 @@ async def serve_proxy_media(request: Request, encoded_url: str) -> StreamingResp
|
||||
)
|
||||
|
||||
|
||||
@app.get("/proxy/media/{encoded_url}/{size}")
|
||||
def serve_proxy_media_resized(
|
||||
request: Request,
|
||||
encoded_url: str,
|
||||
size: int,
|
||||
) -> PlainTextResponse:
|
||||
if size not in {50, 740}:
|
||||
raise ValueError("Unsupported size")
|
||||
|
||||
# Decode the base64-encoded URL
|
||||
url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
# Request the URL (and filter request headers)
|
||||
proxy_resp = httpx.get(
|
||||
url,
|
||||
headers=[
|
||||
(k, v)
|
||||
for (k, v) in request.headers.raw
|
||||
if k.lower()
|
||||
not in [b"host", b"cookie", b"x-forwarded-for", b"x-real-ip", b"user-agent"]
|
||||
]
|
||||
+ [(b"user-agent", USER_AGENT.encode())],
|
||||
)
|
||||
if proxy_resp.status_code != 200:
|
||||
return PlainTextResponse(
|
||||
proxy_resp.content,
|
||||
status_code=proxy_resp.status_code,
|
||||
)
|
||||
|
||||
# Filter the headers
|
||||
proxy_resp_headers = {
|
||||
k: v
|
||||
for (k, v) in proxy_resp.headers.items()
|
||||
if k.lower()
|
||||
in [
|
||||
"content-type",
|
||||
"etag",
|
||||
"cache-control",
|
||||
"expires",
|
||||
"last-modified",
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
out = BytesIO(proxy_resp.content)
|
||||
i = Image.open(out)
|
||||
i.thumbnail((size, size))
|
||||
resized_buf = BytesIO()
|
||||
i.save(resized_buf, format=i.format)
|
||||
resized_buf.seek(0)
|
||||
return PlainTextResponse(
|
||||
resized_buf.read(),
|
||||
media_type=i.get_format_mimetype(), # type: ignore
|
||||
headers=proxy_resp_headers,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to resize {url} on the fly")
|
||||
return PlainTextResponse(
|
||||
proxy_resp.content,
|
||||
headers=proxy_resp_headers,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/attachments/{content_hash}/{filename}")
|
||||
def serve_attachment(
|
||||
content_hash: str,
|
||||
filename: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
upload = (
|
||||
db.query(models.Upload)
|
||||
.filter(
|
||||
models.Upload.content_hash == content_hash,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if not upload:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return FileResponse(
|
||||
UPLOAD_DIR / content_hash,
|
||||
media_type=upload.content_type,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/attachments/thumbnails/{content_hash}/{filename}")
|
||||
def serve_attachment_thumbnail(
|
||||
content_hash: str,
|
||||
filename: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
upload = (
|
||||
db.query(models.Upload)
|
||||
.filter(
|
||||
models.Upload.content_hash == content_hash,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if not upload or not upload.has_thumbnail:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return FileResponse(
|
||||
UPLOAD_DIR / (content_hash + "_resized"),
|
||||
media_type=upload.content_type,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/robots.txt", response_class=PlainTextResponse)
|
||||
async def robots_file():
|
||||
return """User-agent: *
|
||||
|
@@ -17,13 +17,15 @@ from sqlalchemy.orm import relationship
|
||||
from app import activitypub as ap
|
||||
from app.actor import LOCAL_ACTOR
|
||||
from app.actor import Actor as BaseActor
|
||||
from app.ap_object import Attachment
|
||||
from app.ap_object import Object as BaseObject
|
||||
from app.config import BASE_URL
|
||||
from app.database import Base
|
||||
from app.database import now
|
||||
|
||||
|
||||
class Actor(Base, BaseActor):
|
||||
__tablename__ = "actors"
|
||||
__tablename__ = "actor"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
@@ -47,7 +49,7 @@ class InboxObject(Base, BaseObject):
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
|
||||
actor_id = Column(Integer, ForeignKey("actors.id"), nullable=False)
|
||||
actor_id = Column(Integer, ForeignKey("actor.id"), nullable=False)
|
||||
actor: Mapped[Actor] = relationship(Actor, uselist=False)
|
||||
|
||||
server = Column(String, nullable=False)
|
||||
@@ -166,15 +168,48 @@ class OutboxObject(Base, BaseObject):
|
||||
def actor(self) -> BaseActor:
|
||||
return LOCAL_ACTOR
|
||||
|
||||
outbox_object_attachments: Mapped[list["OutboxObjectAttachment"]] = relationship(
|
||||
"OutboxObjectAttachment", uselist=True, backref="outbox_object"
|
||||
)
|
||||
|
||||
@property
|
||||
def attachments(self) -> list[Attachment]:
|
||||
out = []
|
||||
for attachment in self.outbox_object_attachments:
|
||||
url = (
|
||||
BASE_URL
|
||||
+ f"/attachments/{attachment.upload.content_hash}/{attachment.filename}"
|
||||
)
|
||||
out.append(
|
||||
Attachment.parse_obj(
|
||||
{
|
||||
"type": "Document",
|
||||
"mediaType": attachment.upload.content_type,
|
||||
"name": attachment.filename,
|
||||
"url": url,
|
||||
"proxiedUrl": url,
|
||||
"resizedUrl": BASE_URL
|
||||
+ (
|
||||
"/attachments/thumbnails/"
|
||||
f"{attachment.upload.content_hash}"
|
||||
f"/{attachment.filename}"
|
||||
)
|
||||
if attachment.upload.has_thumbnail
|
||||
else None,
|
||||
}
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class Follower(Base):
|
||||
__tablename__ = "followers"
|
||||
__tablename__ = "follower"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
|
||||
actor_id = Column(Integer, ForeignKey("actors.id"), nullable=False, unique=True)
|
||||
actor_id = Column(Integer, ForeignKey("actor.id"), nullable=False, unique=True)
|
||||
actor = relationship(Actor, uselist=False)
|
||||
|
||||
inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=False)
|
||||
@@ -190,7 +225,7 @@ class Following(Base):
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
|
||||
actor_id = Column(Integer, ForeignKey("actors.id"), nullable=False, unique=True)
|
||||
actor_id = Column(Integer, ForeignKey("actor.id"), nullable=False, unique=True)
|
||||
actor = relationship(Actor, uselist=False)
|
||||
|
||||
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
|
||||
@@ -220,7 +255,7 @@ class Notification(Base):
|
||||
notification_type = Column(Enum(NotificationType), nullable=True)
|
||||
is_new = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
actor_id = Column(Integer, ForeignKey("actors.id"), nullable=True)
|
||||
actor_id = Column(Integer, ForeignKey("actor.id"), nullable=True)
|
||||
actor = relationship(Actor, uselist=False)
|
||||
|
||||
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=True)
|
||||
@@ -231,7 +266,7 @@ class Notification(Base):
|
||||
|
||||
|
||||
class OutgoingActivity(Base):
|
||||
__tablename__ = "outgoing_activities"
|
||||
__tablename__ = "outgoing_activity"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
@@ -253,7 +288,7 @@ class OutgoingActivity(Base):
|
||||
|
||||
|
||||
class TaggedOutboxObject(Base):
|
||||
__tablename__ = "tagged_outbox_objects"
|
||||
__tablename__ = "tagged_outbox_object"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("outbox_object_id", "tag", name="uix_tagged_object"),
|
||||
)
|
||||
@@ -266,23 +301,35 @@ class TaggedOutboxObject(Base):
|
||||
tag = Column(String, nullable=False, index=True)
|
||||
|
||||
|
||||
"""
|
||||
class Upload(Base):
|
||||
__tablename__ = "upload"
|
||||
|
||||
filename = Column(String, nullable=False)
|
||||
filehash = Column(String, nullable=False)
|
||||
filesize = Column(Integer, nullable=False)
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
|
||||
content_type: Mapped[str] = Column(String, nullable=False)
|
||||
content_hash = Column(String, nullable=False, unique=True)
|
||||
|
||||
has_thumbnail = Column(Boolean, nullable=False)
|
||||
|
||||
# Only set for images
|
||||
blurhash = Column(String, nullable=True)
|
||||
width = Column(Integer, nullable=True)
|
||||
height = Column(Integer, nullable=True)
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
return self.content_type.startswith("image")
|
||||
|
||||
|
||||
class OutboxObjectAttachment(Base):
|
||||
__tablename__ = "outbox_object_attachment"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
filename = Column(String, nullable=False)
|
||||
|
||||
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
|
||||
outbox_object = relationship(OutboxObject, uselist=False)
|
||||
|
||||
upload_id = Column(Integer, ForeignKey("upload.id"))
|
||||
upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False)
|
||||
upload = relationship(Upload, uselist=False)
|
||||
"""
|
||||
|
@@ -17,8 +17,8 @@ from app import models
|
||||
from app.actor import LOCAL_ACTOR
|
||||
from app.ap_object import Attachment
|
||||
from app.boxes import public_outbox_objects_count
|
||||
from app.config import BASE_URL
|
||||
from app.config import DEBUG
|
||||
from app.config import DOMAIN
|
||||
from app.config import VERSION
|
||||
from app.config import generate_csrf_token
|
||||
from app.config import session_serializer
|
||||
@@ -40,7 +40,7 @@ def _media_proxy_url(url: str | None) -> str:
|
||||
if not url:
|
||||
return "/static/nopic.png"
|
||||
|
||||
if url.startswith(DOMAIN):
|
||||
if url.startswith(BASE_URL):
|
||||
return url
|
||||
|
||||
encoded_url = base64.urlsafe_b64encode(url.encode()).decode()
|
||||
|
@@ -57,7 +57,7 @@
|
||||
{% set metadata = actors_metadata.get(actor.ap_id) %}
|
||||
<div style="display: flex;column-gap: 20px;margin:20px 0 10px 0;" class="actor-box">
|
||||
<div style="flex: 0 0 48px;">
|
||||
<img src="{{ actor.icon_url | media_proxy_url }}" style="max-width:45px;">
|
||||
<img src="{{ actor.icon_url | media_proxy_url }}/50" style="max-width:45px;">
|
||||
</div>
|
||||
<a href="{{ actor.url }}" style="">
|
||||
<div><strong>{{ actor.name or actor.preferred_username }}</strong></div>
|
||||
@@ -90,7 +90,7 @@
|
||||
{% if object.ap_type in ["Note", "Article", "Video"] %}
|
||||
<div class="activity-wrap" id="{{ object.permalink_id }}">
|
||||
<div class="activity-content">
|
||||
<img src="{% if object.actor.icon_url %}{{ object.actor.icon_url | media_proxy_url }}{% else %}/static/nopic.png{% endif %}" alt="" class="actor-icon">
|
||||
<img src="{% if object.actor.icon_url %}{{ object.actor.icon_url | media_proxy_url }}/50{% else %}/static/nopic.png{% endif %}" alt="" class="actor-icon">
|
||||
<div class="activity-header">
|
||||
<strong>{{ object.actor.name or object.actor.preferred_username }}</strong>
|
||||
<span>{{ object.actor.handle }}</span>
|
||||
@@ -107,11 +107,12 @@
|
||||
{{ sensitive_button(object.permalink_id )}}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.attachments and (not object.sensitive or (object.sensitive and request.query_params["show_sensitive"] == object.permalink_id)) %}
|
||||
<div class="activity-attachment">
|
||||
{% for attachment in object.attachments %}
|
||||
{% if attachment.type == "Image" or (attachment | has_media_type("image")) %}
|
||||
<img src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} alt="{{ attachment.name }}"{% endif %} class="attachment">
|
||||
<img src="{{ attachment.resized_url or attachment.proxied_url }}"{% if attachment.name %} alt="{{ attachment.name }}"{% endif %} class="attachment">
|
||||
{% elif attachment.type == "Video" or (attachment | has_media_type("video")) %}
|
||||
<video controls preload="metadata" src="{{ attachment.url | media_proxy_url }}"{% if attachment.name %} title="{{ attachment.name }}"{% endif %} class="attachmeent"></video>
|
||||
{% elif attachment.type == "Audio" or (attachment | has_media_type("audio")) %}
|
||||
|
100
app/uploads.py
Normal file
100
app/uploads.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import hashlib
|
||||
from shutil import COPY_BUFSIZE # type: ignore
|
||||
|
||||
import blurhash # type: ignore
|
||||
from fastapi import UploadFile
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
|
||||
from app import activitypub as ap
|
||||
from app import models
|
||||
from app.config import BASE_URL
|
||||
from app.config import ROOT_DIR
|
||||
from app.database import Session
|
||||
|
||||
UPLOAD_DIR = ROOT_DIR / "data" / "uploads"
|
||||
|
||||
|
||||
def save_upload(db: Session, f: UploadFile) -> models.Upload:
|
||||
# Compute the hash
|
||||
h = hashlib.blake2b(digest_size=32)
|
||||
while True:
|
||||
buf = f.file.read(COPY_BUFSIZE)
|
||||
if not buf:
|
||||
break
|
||||
h.update(buf)
|
||||
|
||||
f.file.seek(0)
|
||||
content_hash = h.hexdigest()
|
||||
|
||||
existing_upload = (
|
||||
db.query(models.Upload)
|
||||
.filter(models.Upload.content_hash == content_hash)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing_upload:
|
||||
logger.info(f"Upload with {content_hash=} already exists")
|
||||
return existing_upload
|
||||
|
||||
logger.info(f"Creating new Upload with {content_hash=}")
|
||||
dest_filename = UPLOAD_DIR / content_hash
|
||||
with open(dest_filename, "wb") as dest:
|
||||
while True:
|
||||
buf = f.file.read(COPY_BUFSIZE)
|
||||
if not buf:
|
||||
break
|
||||
dest.write(buf)
|
||||
|
||||
has_thumbnail = False
|
||||
image_blurhash = None
|
||||
width = None
|
||||
height = None
|
||||
|
||||
if f.content_type.startswith("image"):
|
||||
with open(dest_filename, "rb") as df:
|
||||
image_blurhash = blurhash.encode(df, x_components=4, y_components=3)
|
||||
|
||||
try:
|
||||
with Image.open(dest_filename) as i:
|
||||
width, height = i.size
|
||||
i.thumbnail((740, 740))
|
||||
i.save(UPLOAD_DIR / f"{content_hash}_resized", format=i.format)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to created thumbnail for {f.filename}/{content_hash}"
|
||||
)
|
||||
else:
|
||||
has_thumbnail = True
|
||||
logger.info("Thumbnail generated")
|
||||
|
||||
new_upload = models.Upload(
|
||||
content_type=f.content_type,
|
||||
content_hash=content_hash,
|
||||
has_thumbnail=has_thumbnail,
|
||||
blurhash=image_blurhash,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
db.add(new_upload)
|
||||
db.commit()
|
||||
|
||||
return new_upload
|
||||
|
||||
|
||||
def upload_to_attachment(upload: models.Upload, filename: str) -> ap.RawObject:
|
||||
extra_attachment_fields = {}
|
||||
if upload.blurhash:
|
||||
extra_attachment_fields.update(
|
||||
{
|
||||
"blurhash": upload.blurhash,
|
||||
"height": upload.height,
|
||||
"width": upload.width,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"type": "Document",
|
||||
"mediaType": upload.content_type,
|
||||
"name": filename,
|
||||
"url": BASE_URL + f"/attachments/{upload.content_hash}",
|
||||
**extra_attachment_fields,
|
||||
}
|
Reference in New Issue
Block a user