yggdrasil/api/notes/
attachments.rs1use crate::api::error::AppError;
3use crate::db::pool::get_conn;
4use axum::{
5 extract::{Multipart, Path},
6 http::{HeaderMap, StatusCode},
7 response::{IntoResponse, Response},
8 Json,
9};
10
11async fn owner(headers: &HeaderMap) -> Option<i32> {
13 crate::api::auth::require_admin_from_headers(headers)
14 .await
15 .ok()
16 .map(|user| user.id)
17}
18
19pub async fn upload(headers: HeaderMap, mut multipart: Multipart) -> Response {
20 let Some(owner_id) = owner(&headers).await else {
21 return StatusCode::UNAUTHORIZED.into_response();
22 };
23 let ip = crate::api::rate_limit::get_client_ip(&headers).await;
24 if crate::api::rate_limit::check_upload_limit(&ip).is_err() {
25 return StatusCode::TOO_MANY_REQUESTS.into_response();
26 }
27 let data = match multipart.next_field().await {
28 Ok(Some(field)) => match field.bytes().await {
29 Ok(data) => data,
30 _ => return StatusCode::BAD_REQUEST.into_response(),
31 },
32 _ => return StatusCode::BAD_REQUEST.into_response(),
33 };
34 if data.len() > 5 * 1024 * 1024 {
35 return StatusCode::PAYLOAD_TOO_LARGE.into_response();
36 }
37 let Some(mime) = crate::api::upload::detect_mime(&data) else {
38 return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response();
39 };
40 let check = data.clone();
41 let valid = tokio::task::spawn_blocking(move || {
42 if crate::api::image::upload_dimensions(&check, mime).is_err() {
43 return false;
44 }
45 if mime == "image/webp" {
46 return crate::infra::webp::decode(&check).is_ok();
47 }
48 let Ok(mut reader) =
49 image::ImageReader::new(std::io::Cursor::new(check)).with_guessed_format()
50 else {
51 return false;
52 };
53 reader.limits(crate::api::image::image_reader_limits());
54 reader.decode().is_ok()
55 })
56 .await
57 .unwrap_or(false);
58 if !valid {
59 return StatusCode::BAD_REQUEST.into_response();
60 }
61 let Ok(client) = get_conn().await else {
62 return StatusCode::SERVICE_UNAVAILABLE.into_response();
63 };
64 let id = uuid::Uuid::new_v4();
65 if client
66 .execute(
67 "INSERT INTO note_attachments(id,owner_id,mime,data) VALUES ($1,$2,$3,$4)",
68 &[&id, &owner_id, &mime, &&data[..]],
69 )
70 .await
71 .is_err()
72 {
73 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
74 }
75 Json(serde_json::json!({"success":true,"url":format!("/note-media/{id}")})).into_response()
76}
77
78pub async fn read(headers: HeaderMap, Path(id): Path<String>) -> Response {
79 let Ok(id) = uuid::Uuid::parse_str(&id) else {
80 return StatusCode::NOT_FOUND.into_response();
81 };
82 let owner_id = owner(&headers).await;
83 let Ok(client) = get_conn().await else {
84 return StatusCode::SERVICE_UNAVAILABLE.into_response();
85 };
86 let row = client.query_opt("SELECT mime,data FROM note_attachments a WHERE a.id=$1 AND
87 (a.owner_id=$2 OR EXISTS (SELECT 1 FROM note_revision_attachments r JOIN notes n ON n.id=r.note_id
88 WHERE r.attachment_id=a.id AND r.version=n.published_version AND n.deleted_at IS NULL))", &[&id,&owner_id]).await;
89 let Ok(Some(row)) = row else {
90 return StatusCode::NOT_FOUND.into_response();
91 };
92 let mime: String = row.get(0);
93 let data: Vec<u8> = row.get(1);
94 (
96 [
97 ("content-type", mime.as_str()),
98 ("cache-control", "private, no-store"),
99 ("x-content-type-options", "nosniff"),
100 ("referrer-policy", "same-origin"),
101 ],
102 data,
103 )
104 .into_response()
105}
106
107pub async fn sync_refs(
108 tx: &deadpool_postgres::Transaction<'_>,
109 owner_id: i32,
110 id: i32,
111 version: i32,
112 html: &str,
113) -> Result<(), AppError> {
114 static MEDIA: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
115 regex::Regex::new(
118 r#"<(?:img|a)\b[^>]*\s(?:src|href)="/note-media/([0-9a-fA-F-]{36})(?:[?#][^"]*)?""#,
119 )
120 .expect("note media regex")
121 });
122 for cap in MEDIA.captures_iter(html) {
123 let attachment = uuid::Uuid::parse_str(&cap[1])
124 .map_err(|_| AppError::BadRequest("图片地址无效".into()))?;
125 let changed = tx.execute("INSERT INTO note_revision_attachments(note_id,version,attachment_id)
126 SELECT $1,$2,id FROM note_attachments WHERE id=$3 AND owner_id=$4 ON CONFLICT DO NOTHING", &[&id,&version,&attachment,&owner_id]).await.map_err(AppError::tx)?;
127 if changed == 0 {
128 let exists: bool = tx.query_one("SELECT EXISTS(SELECT 1 FROM note_revision_attachments WHERE note_id=$1 AND version=$2 AND attachment_id=$3)", &[&id,&version,&attachment]).await.map_err(AppError::query)?.get(0);
129 if !exists {
130 return Err(AppError::Forbidden("图片不存在或不属于当前账号"));
131 }
132 }
133 }
134 for path in crate::api::posts::helpers::extract_asset_paths(html, None) {
135 tx.execute("INSERT INTO note_asset_refs(note_id,version,asset_id) SELECT $1,$2,id FROM assets WHERE path=$3 ON CONFLICT DO NOTHING", &[&id,&version,&path]).await.map_err(AppError::tx)?;
136 }
137 Ok(())
138}