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