Add paste-a-URL upload: fetch links server-side to preserve GIF/video

2e5cc52 · gmorell · 2026-06-29 17:46

10 files +248 -3
Message
{commit_body(@commit)}

Files changed

modified assets/f/js/src.js
+39 −1
@@ -97,10 +97,42 @@
97 97 }
98 98 };
99 99
100 + // Fetch a pasted link server-side. Going through the server keeps the
101 + // original bytes (animated GIFs, videos) that a clipboard image-paste would
102 + // otherwise flatten into a single-frame PNG.
103 + const uploadUrl = async (url) => {
104 + const status = showToast(`Fetching ${url}…`, { kind: "info", ttl: 0 });
105 + try {
106 + const res = await fetch("/uploads/url", {
107 + method: "POST",
108 + headers: { "Content-Type": "application/x-www-form-urlencoded" },
109 + body: new URLSearchParams({ url }),
110 + credentials: "same-origin",
111 + });
112 + status.remove();
113 + if (res.redirected && res.url.includes("/login")) {
114 + showToast("Sign in to upload.", { kind: "warning" });
115 + return;
116 + }
117 + if (!res.ok) {
118 + showToast(`Fetch failed (${res.status}).`, { kind: "error" });
119 + return;
120 + }
121 + const href = res.redirected ? res.url : "/uploads";
122 + showToast("Fetched link — view", { kind: "success", href });
123 + } catch (e) {
124 + status.remove();
125 + showToast(`Fetch error: ${e.message || e}`, { kind: "error" });
126 + }
127 + };
128 +
129 + const looksLikeUrl = (text) => /^https?:\/\/\S+$/i.test((text || "").trim());
130 +
100 131 document.addEventListener("paste", (e) => {
101 132 if (!document.body.dataset.authed) return;
102 133 if (isTypingTarget(e.target)) return;
103 − const items = e.clipboardData && e.clipboardData.items;
134 + const cd = e.clipboardData;
135 + const items = cd && cd.items;
104 136 if (!items) return;
105 137 for (const it of items) {
106 138 if (it.kind !== "file") continue;
@@ -111,6 +143,12 @@
111 143 if (!attachToQuickForm(file)) uploadDirect(file);
112 144 return;
113 145 }
146 + // No file on the clipboard — fall back to a pasted link.
147 + const text = cd.getData && cd.getData("text/plain");
148 + if (looksLikeUrl(text)) {
149 + e.preventDefault();
150 + uploadUrl(text.trim());
151 + }
114 152 });
115 153
116 154 // When the quick-upload form is on the page, also preview manual file picks.
modified src/bin/webservice.rs
+2 −0
@@ -77,6 +77,7 @@ async fn main() -> Result<(), anyhow::Error> {
77 77 .route("/api-tokens", get(handlers::show_api_tokens).post(handlers::create_api_token))
78 78 .route("/api-tokens/{id}/delete", post(handlers::delete_api_token))
79 79 .route("/uploads", get(handlers::list_uploads).post(handlers::upload_file))
80 + .route("/uploads/url", post(handlers::upload_from_url))
80 81 .route("/uploads/{id}", get(handlers::show_upload))
81 82 .route("/uploads/{id}/jobs", post(handlers::enqueue_job))
82 83 .route("/uploads/{id}/tags", post(handlers::add_tags))
@@ -95,6 +96,7 @@ async fn main() -> Result<(), anyhow::Error> {
95 96 let api_routes = Router::new()
96 97 .route("/profile", get(api_profile))
97 98 .route("/uploads", get(handlers::api_list_uploads).post(handlers::api_upload_file))
99 + .route("/uploads/url", post(handlers::api_upload_from_url))
98 100 .route("/uploads/{id}", get(handlers::api_show_upload))
99 101 .route("/uploads/{id}/tags", post(handlers::api_add_tags))
100 102 .route("/search", get(handlers::api_search_uploads))
modified src/handlers/mod.rs
+2 −2
@@ -13,6 +13,6 @@ pub use failed::{
13 13 api_requeue_all_dlqs, api_requeue_dlq_kind,
14 14 };
15 15 pub use uploads::{
16 − list_uploads, upload_file, show_upload, enqueue_job, add_tags, search_uploads,
17 − api_list_uploads, api_upload_file, api_show_upload, api_add_tags, api_search_uploads,
16 + list_uploads, upload_file, upload_from_url, show_upload, enqueue_job, add_tags, search_uploads,
17 + api_list_uploads, api_upload_file, api_upload_from_url, api_show_upload, api_add_tags, api_search_uploads,
18 18 };
modified src/handlers/uploads.rs
+205 −0
@@ -134,6 +134,35 @@ pub async fn upload_file(
134 134 }
135 135 }
136 136
137 +#[derive(Debug, Deserialize)]
138 +pub struct UrlUploadForm {
139 + pub url: String,
140 + pub item_name: Option<String>,
141 + pub item_description: Option<String>,
142 + pub tags: Option<String>,
143 +}
144 +
145 +/// POST /uploads/url — fetch a remote file server-side and ingest it. Lets the
146 +/// client paste a link (e.g. an animated GIF or video URL) and keep the
147 +/// original bytes, which a clipboard image-paste would otherwise flatten to PNG.
148 +pub async fn upload_from_url(
149 + State(state): State<AppState>,
150 + user: UserContext,
151 + axum::Form(form): axum::Form<UrlUploadForm>,
152 +) -> Response {
153 + match ingest_url(&state, &user, form).await {
154 + Ok(id) => Redirect::to(&format!("/uploads/{}", id)).into_response(),
155 + Err(IngestError::TooLarge { actual, limit }) => upload_redirect_error(&format!(
156 + "Upload too large ({}). Maximum is {}.",
157 + human_bytes(actual),
158 + human_bytes(limit)
159 + ))
160 + .into_response(),
161 + Err(IngestError::BadRequest(msg)) => upload_redirect_error(&msg).into_response(),
162 + Err(IngestError::Status(code)) => code.into_response(),
163 + }
164 +}
165 +
137 166 #[derive(Debug, Deserialize)]
138 167 pub struct AddTagsForm {
139 168 pub tags: Option<String>,
@@ -395,6 +424,33 @@ pub async fn api_upload_file(
395 424 }
396 425 }
397 426
427 +/// POST /api/uploads/url — JSON path; fetch a remote file server-side and
428 +/// return the new upload id. Body: `{"url": "...", "item_name"?: "...", ...}`.
429 +pub async fn api_upload_from_url(
430 + State(state): State<AppState>,
431 + user: UserContext,
432 + Json(form): Json<UrlUploadForm>,
433 +) -> Response {
434 + match ingest_url(&state, &user, form).await {
435 + Ok(id) => Json(serde_json::json!({ "id": id })).into_response(),
436 + Err(IngestError::TooLarge { actual, limit }) => (
437 + StatusCode::PAYLOAD_TOO_LARGE,
438 + Json(serde_json::json!({
439 + "error": "payload_too_large",
440 + "max_bytes": limit,
441 + "received_bytes": actual,
442 + })),
443 + )
444 + .into_response(),
445 + Err(IngestError::BadRequest(msg)) => (
446 + StatusCode::BAD_REQUEST,
447 + Json(serde_json::json!({"error": msg})),
448 + )
449 + .into_response(),
450 + Err(IngestError::Status(code)) => code.into_response(),
451 + }
452 +}
453 +
398 454 pub async fn api_show_upload(
399 455 State(state): State<AppState>,
400 456 user: UserContext,
@@ -512,6 +568,48 @@ async fn ingest_multipart(
512 568 let bytes = file_bytes.ok_or_else(|| IngestError::BadRequest("No file selected.".into()))?;
513 569 let original_name = original_name.unwrap_or_else(|| "upload.bin".to_string());
514 570
571 + store_upload(
572 + state,
573 + user,
574 + StoredUpload {
575 + bytes,
576 + original_name,
577 + content_type,
578 + item_name,
579 + item_description,
580 + tags_raw,
581 + },
582 + )
583 + .await
584 +}
585 +
586 +/// Bytes plus metadata ready to be persisted — the common shape every ingest
587 +/// path (multipart upload, URL fetch) converges on.
588 +struct StoredUpload {
589 + bytes: Vec<u8>,
590 + original_name: String,
591 + content_type: Option<String>,
592 + item_name: Option<String>,
593 + item_description: Option<String>,
594 + tags_raw: Option<String>,
595 +}
596 +
597 +/// Shared tail of every ingest path: persist to storage, dedupe per user,
598 +/// insert the row, attach tags, and enqueue processing jobs.
599 +async fn store_upload(
600 + state: &AppState,
601 + user: &UserContext,
602 + data: StoredUpload,
603 +) -> Result<Uuid, IngestError> {
604 + let StoredUpload {
605 + bytes,
606 + original_name,
607 + content_type,
608 + item_name,
609 + item_description,
610 + tags_raw,
611 + } = data;
612 +
515 613 let extension = std::path::Path::new(&original_name)
516 614 .extension()
517 615 .and_then(|s| s.to_str())
@@ -580,6 +678,113 @@ async fn ingest_multipart(
580 678 Ok(inserted.id)
581 679 }
582 680
681 +/// Fetch a remote URL server-side and hand the bytes to [`store_upload`].
682 +/// Fetching server-side (rather than relying on the clipboard) preserves the
683 +/// original encoding — animated GIFs and videos arrive intact.
684 +async fn ingest_url(
685 + state: &AppState,
686 + user: &UserContext,
687 + form: UrlUploadForm,
688 +) -> Result<Uuid, IngestError> {
689 + let url = form.url.trim();
690 + // Only fetch real web URLs. This is the SSRF guard surface — http(s) only,
691 + // no file://, no scheme-relative or internal protocols.
692 + if !(url.starts_with("http://") || url.starts_with("https://")) {
693 + return Err(IngestError::BadRequest(
694 + "Only http(s) URLs can be fetched.".into(),
695 + ));
696 + }
697 +
698 + let client = reqwest::Client::builder()
699 + .timeout(std::time::Duration::from_secs(30))
700 + .build()
701 + .map_err(|e| {
702 + error!(error = %e, "build http client failed");
703 + IngestError::Status(StatusCode::INTERNAL_SERVER_ERROR)
704 + })?;
705 +
706 + let resp = client
707 + .get(url)
708 + .send()
709 + .await
710 + .map_err(|e| IngestError::BadRequest(format!("Could not fetch URL: {e}")))?;
711 +
712 + if !resp.status().is_success() {
713 + return Err(IngestError::BadRequest(format!(
714 + "Remote server returned {} for that URL.",
715 + resp.status().as_u16()
716 + )));
717 + }
718 +
719 + // Early bail on advertised size before we buffer the body.
720 + if let Some(len) = resp.content_length() {
721 + if len as usize > state.config.max_upload_bytes {
722 + return Err(IngestError::TooLarge {
723 + actual: len as usize,
724 + limit: state.config.max_upload_bytes,
725 + });
726 + }
727 + }
728 +
729 + // Content-Type header without any `; charset=…` parameters.
730 + let content_type = resp
731 + .headers()
732 + .get(axum::http::header::CONTENT_TYPE)
733 + .and_then(|v| v.to_str().ok())
734 + .map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
735 + .filter(|s| !s.is_empty());
736 +
737 + let bytes = resp
738 + .bytes()
739 + .await
740 + .map_err(|e| IngestError::BadRequest(format!("Could not read remote body: {e}")))?
741 + .to_vec();
742 + if bytes.len() > state.config.max_upload_bytes {
743 + return Err(IngestError::TooLarge {
744 + actual: bytes.len(),
745 + limit: state.config.max_upload_bytes,
746 + });
747 + }
748 +
749 + // Derive a filename from the URL path; backfill an extension from the
750 + // content type when the path has none (common for query-driven URLs).
751 + let mut original_name = url
752 + .split(['?', '#'])
753 + .next()
754 + .unwrap_or(url)
755 + .rsplit('/')
756 + .next()
757 + .unwrap_or("")
758 + .trim()
759 + .to_string();
760 + if original_name.is_empty() {
761 + original_name = "download".to_string();
762 + }
763 + if std::path::Path::new(&original_name).extension().is_none() {
764 + if let Some(ext) = content_type
765 + .as_deref()
766 + .and_then(mime_guess::get_mime_extensions_str)
767 + .and_then(|exts| exts.first())
768 + {
769 + original_name = format!("{original_name}.{ext}");
770 + }
771 + }
772 +
773 + store_upload(
774 + state,
775 + user,
776 + StoredUpload {
777 + bytes,
778 + original_name,
779 + content_type,
780 + item_name: form.item_name.filter(|s| !s.trim().is_empty()),
781 + item_description: form.item_description.filter(|s| !s.trim().is_empty()),
782 + tags_raw: form.tags,
783 + },
784 + )
785 + .await
786 +}
787 +
583 788 async fn enqueue_default_jobs(state: &AppState, upload: &uploads::Model) {
584 789 let is_image = upload
585 790 .content_type

Parents: 14ab219