⭐
Log in to mark this repository.
Add paste-a-URL upload: fetch links server-side to preserve GIF/video
2e5cc52 · gmorell · 2026-06-29 17:46
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