Skip to main content

yggdrasil/api/notes/
attachments.rs

1//! 笔记图片:原图随数据库备份;仅所有者或引用该图片的公开版本可读取。
2use 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    // 每次重新鉴权,撤回发布后不残留可公开复用的浏览器/CDN缓存。
94    (
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        // 输入已由 Markdown 渲染器清洗并序列化为双引号 HTML 属性。
115        // 只把真正的本站图片 / 链接视为引用,代码示例和外站同路径不能开放私密图片。
116        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}