feat: improve media delivery and web upload pipeline
All checks were successful
CI / test (push) Successful in 26s
All checks were successful
CI / test (push) Successful in 26s
- make minio bucket downloadable for direct media links - switch object keys to random uuid-based names - add client-side image compression before upload
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import re
|
import mimetypes
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -27,14 +27,13 @@ ALLOWED_MIME_TYPES = {
|
|||||||
"text/plain",
|
"text/plain",
|
||||||
}
|
}
|
||||||
|
|
||||||
_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+")
|
def _extension_from_mime(file_type: str) -> str:
|
||||||
|
ext = mimetypes.guess_extension(file_type)
|
||||||
|
if not ext:
|
||||||
def _sanitize_filename(file_name: str) -> str:
|
return ".bin"
|
||||||
sanitized = _SAFE_NAME_RE.sub("_", file_name).strip("._")
|
if ext == ".jpe":
|
||||||
if not sanitized:
|
return ".jpg"
|
||||||
sanitized = "file.bin"
|
return ext
|
||||||
return sanitized[:120]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_file_url(bucket: str, object_key: str) -> str:
|
def _build_file_url(bucket: str, object_key: str) -> str:
|
||||||
@@ -71,8 +70,8 @@ def _get_s3_client(endpoint_url: str):
|
|||||||
async def generate_upload_url(payload: UploadUrlRequest) -> UploadUrlResponse:
|
async def generate_upload_url(payload: UploadUrlRequest) -> UploadUrlResponse:
|
||||||
_validate_media(payload.file_type, payload.file_size)
|
_validate_media(payload.file_type, payload.file_size)
|
||||||
|
|
||||||
file_name = _sanitize_filename(payload.file_name)
|
extension = _extension_from_mime(payload.file_type)
|
||||||
object_key = f"uploads/{uuid4()}-{file_name}"
|
object_key = f"uploads/{uuid4().hex}{extension}"
|
||||||
bucket = settings.s3_bucket_name
|
bucket = settings.s3_bucket_name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ services:
|
|||||||
- -ec
|
- -ec
|
||||||
- >
|
- >
|
||||||
mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin};
|
mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin};
|
||||||
mc mb --ignore-existing local/${S3_BUCKET_NAME:-messenger-media}
|
mc mb --ignore-existing local/${S3_BUCKET_NAME:-messenger-media};
|
||||||
|
mc anonymous set download local/${S3_BUCKET_NAME:-messenger-media}
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -84,6 +84,55 @@ export function MessageComposer() {
|
|||||||
return "file";
|
return "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadImageFromFile(file: File): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
image.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error("Failed to load image"));
|
||||||
|
};
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressImageForWeb(file: File): Promise<File> {
|
||||||
|
const image = await loadImageFromFile(file);
|
||||||
|
const maxSide = 1920;
|
||||||
|
const ratio = Math.min(1, maxSide / Math.max(image.width, image.height));
|
||||||
|
const width = Math.max(1, Math.round(image.width * ratio));
|
||||||
|
const height = Math.max(1, Math.round(image.height * ratio));
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
ctx.drawImage(image, 0, 0, width, height);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob((result) => resolve(result), "image/jpeg", 0.82);
|
||||||
|
});
|
||||||
|
if (!blob || blob.size >= file.size) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
const baseName = file.name.replace(/\.[^/.]+$/, "");
|
||||||
|
return new File([blob], `${baseName || "image"}-web.jpg`, { type: "image/jpeg" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareFileForUpload(file: File, fileType: "file" | "image" | "video" | "audio"): Promise<File> {
|
||||||
|
if (fileType === "image") {
|
||||||
|
return compressImageForWeb(file);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
async function startRecord() {
|
async function startRecord() {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
@@ -129,7 +178,8 @@ export function MessageComposer() {
|
|||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await handleUpload(selectedFile, selectedType);
|
const uploadFile = await prepareFileForUpload(selectedFile, selectedType);
|
||||||
|
await handleUpload(uploadFile, selectedType);
|
||||||
if (previewUrl) {
|
if (previewUrl) {
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user