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
11/// 管理员会话的用户 id;未登录、会话失效与非 admin 一律视为无所有者。
12async 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    // 每次重新鉴权,撤回发布后不残留可公开复用的浏览器/CDN缓存。
95    (
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        // 输入已由 Markdown 渲染器清洗并序列化为双引号 HTML 属性。
116        // 只把真正的本站图片 / 链接视为引用,代码示例和外站同路径不能开放私密图片。
117        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}