some changes
This commit is contained in:
+88
-9
@@ -99,7 +99,11 @@ pub async fn upload_pad_file(
|
||||
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
|
||||
key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
|
||||
}
|
||||
let url = format!("/f/{}/{}", file_token, stored);
|
||||
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
|
||||
let public_url = crate::file_urls::public_file_url(
|
||||
state.files_public_url.as_deref(),
|
||||
&stored_url,
|
||||
);
|
||||
let mime = mime_guess::from_path(&stored)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
@@ -109,8 +113,16 @@ pub async fn upload_pad_file(
|
||||
.put(&key, bytes.clone().into(), &mime, &cache_control)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
||||
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
|
||||
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
|
||||
db::register_pad_file(
|
||||
&state.db,
|
||||
pad.id,
|
||||
&stored,
|
||||
&stored_url,
|
||||
&mime,
|
||||
bytes.len() as i64,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime})))
|
||||
}
|
||||
|
||||
pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool {
|
||||
@@ -136,6 +148,26 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str)
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn content_references_stored_file(
|
||||
content: &str,
|
||||
filename: &str,
|
||||
stored_url: &str,
|
||||
public_base: Option<&str>,
|
||||
) -> bool {
|
||||
if content_references_file(content, filename, stored_url) {
|
||||
return true;
|
||||
}
|
||||
let canonical = crate::file_urls::canonical_file_path(stored_url);
|
||||
if canonical
|
||||
.as_deref()
|
||||
.is_some_and(|url| url != stored_url && content.contains(url))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let public_url = crate::file_urls::public_file_url(public_base, stored_url);
|
||||
public_url != stored_url && content.contains(&public_url)
|
||||
}
|
||||
|
||||
pub async fn pad_files(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
@@ -153,7 +185,12 @@ pub async fn pad_files(
|
||||
.await?;
|
||||
let mut files = db::list_pad_files(&state.db, pad.id).await?;
|
||||
for file in &mut files {
|
||||
let attached = content_references_file(&pad.content, &file.filename, &file.url);
|
||||
let attached = content_references_stored_file(
|
||||
&pad.content,
|
||||
&file.filename,
|
||||
&file.url,
|
||||
state.files_public_url.as_deref(),
|
||||
);
|
||||
if attached != file.is_attached {
|
||||
db::set_pad_file_attached(&state.db, file.id, attached).await?;
|
||||
file.is_attached = attached;
|
||||
@@ -164,6 +201,10 @@ pub async fn pad_files(
|
||||
};
|
||||
}
|
||||
file.created_at = db::normalize_timestamp(&file.created_at);
|
||||
file.url = crate::file_urls::public_file_url(
|
||||
state.files_public_url.as_deref(),
|
||||
&file.url,
|
||||
);
|
||||
}
|
||||
Ok(Json(files))
|
||||
}
|
||||
@@ -298,7 +339,11 @@ pub async fn upload_note_file(
|
||||
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
|
||||
key = crate::storage::object_key("notes", note.id, &file_token, &stored);
|
||||
}
|
||||
let url = format!("/f/{}/{}", file_token, stored);
|
||||
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
|
||||
let public_url = crate::file_urls::public_file_url(
|
||||
state.files_public_url.as_deref(),
|
||||
&stored_url,
|
||||
);
|
||||
let mime = mime_guess::from_path(&stored)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
@@ -308,8 +353,16 @@ pub async fn upload_note_file(
|
||||
.put(&key, bytes.clone().into(), &mime, &cache_control)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
||||
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
|
||||
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
|
||||
db::register_note_file(
|
||||
&state.db,
|
||||
note.id,
|
||||
&stored,
|
||||
&stored_url,
|
||||
&mime,
|
||||
bytes.len() as i64,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime})))
|
||||
}
|
||||
|
||||
pub async fn delete_note(
|
||||
@@ -382,7 +435,12 @@ pub async fn note_files(
|
||||
.await?;
|
||||
let mut files = db::list_note_files(&state.db, note.id).await?;
|
||||
for file in &mut files {
|
||||
let attached = content_references_file(¬e.content, &file.filename, &file.url);
|
||||
let attached = content_references_stored_file(
|
||||
¬e.content,
|
||||
&file.filename,
|
||||
&file.url,
|
||||
state.files_public_url.as_deref(),
|
||||
);
|
||||
if attached != file.is_attached {
|
||||
db::set_note_file_attached(&state.db, file.id, attached).await?;
|
||||
file.is_attached = attached;
|
||||
@@ -393,6 +451,10 @@ pub async fn note_files(
|
||||
};
|
||||
}
|
||||
file.created_at = db::normalize_timestamp(&file.created_at);
|
||||
file.url = crate::file_urls::public_file_url(
|
||||
state.files_public_url.as_deref(),
|
||||
&file.url,
|
||||
);
|
||||
}
|
||||
Ok(Json(files))
|
||||
}
|
||||
@@ -580,7 +642,7 @@ fn sanitize_filename(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_safe_inline_image_mime;
|
||||
use super::{content_references_stored_file, is_safe_inline_image_mime};
|
||||
|
||||
#[test]
|
||||
fn only_raster_images_are_inline() {
|
||||
@@ -590,4 +652,21 @@ mod tests {
|
||||
assert!(!is_safe_inline_image_mime("text/html"));
|
||||
assert!(!is_safe_inline_image_mime("application/xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_references_survive_origin_changes() {
|
||||
let stored = "/f/token/image.png";
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
stored,
|
||||
None,
|
||||
));
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
"https://old-files.example.com/f/token/image.png",
|
||||
Some("https://new-files.example.com"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-12
@@ -162,16 +162,6 @@ pub struct MarkdownFileReference {
|
||||
mime_type: String,
|
||||
}
|
||||
|
||||
impl From<db::NoteFile> for MarkdownFileReference {
|
||||
fn from(file: db::NoteFile) -> Self {
|
||||
Self {
|
||||
filename: file.filename,
|
||||
url: file.url,
|
||||
mime_type: file.mime_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn markdown_file_references(
|
||||
state: &SharedState,
|
||||
pad_id: Option<i64>,
|
||||
@@ -189,10 +179,24 @@ pub(crate) async fn markdown_file_references(
|
||||
.into_iter()
|
||||
.filter(|file| {
|
||||
content
|
||||
.map(|value| files::content_references_file(value, &file.filename, &file.url))
|
||||
.map(|value| {
|
||||
files::content_references_stored_file(
|
||||
value,
|
||||
&file.filename,
|
||||
&file.url,
|
||||
state.files_public_url.as_deref(),
|
||||
)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(Into::into)
|
||||
.map(|file| MarkdownFileReference {
|
||||
filename: file.filename,
|
||||
url: crate::file_urls::public_file_url(
|
||||
state.files_public_url.as_deref(),
|
||||
&file.url,
|
||||
),
|
||||
mime_type: file.mime_type,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ pub struct Config {
|
||||
pub asset_version: String,
|
||||
pub asset_cache_max_age_seconds: u64,
|
||||
pub file_cache_max_age_seconds: u64,
|
||||
pub files_public_url: Option<String>,
|
||||
pub smtp: Option<crate::state::SmtpConfig>,
|
||||
pub registration_enabled: bool,
|
||||
pub account_confirmation_required: bool,
|
||||
@@ -83,6 +84,8 @@ impl Config {
|
||||
let unconfirmed_account_ttl_days =
|
||||
values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
|
||||
let files_dir = values.get("FILES_DIR", "data/files");
|
||||
let files_public_url =
|
||||
crate::file_urls::normalize_public_base(values.optional("FILES_PUBLIC_URL"))?;
|
||||
|
||||
let storage = match values
|
||||
.get("STORAGE_DRIVER", "local")
|
||||
@@ -172,6 +175,7 @@ impl Config {
|
||||
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
file_cache_max_age_seconds: values
|
||||
.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
files_public_url,
|
||||
smtp,
|
||||
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
|
||||
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
||||
|
||||
@@ -16,6 +16,7 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
|
||||
"DATABASE_MAX_CONNECTIONS",
|
||||
"STATIC_DIR",
|
||||
"FILES_DIR",
|
||||
"FILES_PUBLIC_URL",
|
||||
"STORAGE_DRIVER",
|
||||
"UPLOAD_MAX_SIZE_MB",
|
||||
"ASSET_CACHE_MAX_AGE_SECONDS",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
const FILE_ROUTE_PREFIX: &str = "/f/";
|
||||
|
||||
pub fn normalize_public_base(value: Option<String>) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let candidate = if value.starts_with("http://") || value.starts_with("https://") {
|
||||
value.trim_end_matches('/').to_owned()
|
||||
} else {
|
||||
format!("https://{}", value.trim_end_matches('/'))
|
||||
};
|
||||
let authority = candidate
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| candidate.strip_prefix("http://"))
|
||||
.ok_or("FILES_PUBLIC_URL must use http:// or https://")?;
|
||||
|
||||
if authority.is_empty()
|
||||
|| authority.chars().any(|character| {
|
||||
matches!(character, '/' | '\\' | '?' | '#' | '@') || character.is_whitespace()
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"FILES_PUBLIC_URL must be a domain or HTTP(S) origin without a path, query, credentials, or fragment"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(candidate))
|
||||
}
|
||||
|
||||
pub fn canonical_file_path(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
let path = if value.starts_with('/') {
|
||||
value
|
||||
} else {
|
||||
let (_, after_scheme) = value.split_once("://")?;
|
||||
let path_start = after_scheme.find('/')?;
|
||||
&after_scheme[path_start..]
|
||||
};
|
||||
let path = path
|
||||
.split(|character| matches!(character, '?' | '#'))
|
||||
.next()
|
||||
.unwrap_or(path);
|
||||
if !path.starts_with(FILE_ROUTE_PREFIX) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = path.trim_start_matches('/').split('/');
|
||||
let route = parts.next()?;
|
||||
let token = parts.next()?;
|
||||
let filename = parts.next()?;
|
||||
if route != "f" || token.is_empty() || filename.is_empty() || parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!("/f/{token}/{filename}"))
|
||||
}
|
||||
|
||||
pub fn stored_file_path(token: &str, filename: &str) -> String {
|
||||
format!("/f/{token}/{filename}")
|
||||
}
|
||||
|
||||
pub fn public_file_url(public_base: Option<&str>, stored_url: &str) -> String {
|
||||
let Some(canonical) = canonical_file_path(stored_url) else {
|
||||
return stored_url.to_owned();
|
||||
};
|
||||
match public_base {
|
||||
Some(base) => format!("{}{canonical}", base.trim_end_matches('/')),
|
||||
None => canonical,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_bare_domain_and_http_origins() {
|
||||
assert_eq!(
|
||||
normalize_public_base(Some("files.note.example.com".into())).unwrap(),
|
||||
Some("https://files.note.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_public_base(Some("http://localhost:3001/".into())).unwrap(),
|
||||
Some("http://localhost:3001".into())
|
||||
);
|
||||
assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_origin_public_urls() {
|
||||
assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err());
|
||||
assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err());
|
||||
assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err());
|
||||
assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_canonical_path_from_relative_and_absolute_urls() {
|
||||
assert_eq!(
|
||||
canonical_file_path("/f/token/image.png"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_file_path("https://files.example.com/f/token/image.png"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_file_path("https://files.example.com/f/token/image.png?download=1"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(canonical_file_path("/files/token/image.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_between_custom_origin_and_application_path() {
|
||||
let stored = "/f/token/manual.pdf";
|
||||
assert_eq!(public_file_url(None, stored), stored);
|
||||
assert_eq!(
|
||||
public_file_url(Some("https://files.example.com"), stored),
|
||||
"https://files.example.com/f/token/manual.pdf"
|
||||
);
|
||||
assert_eq!(
|
||||
public_file_url(None, "https://old.example.com/f/token/manual.pdf"),
|
||||
stored
|
||||
);
|
||||
assert_eq!(
|
||||
public_file_url(Some("https://files.example.com"), "/invalid/path"),
|
||||
"/invalid/path"
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-4
@@ -15,6 +15,7 @@ mod cache;
|
||||
mod config;
|
||||
mod database;
|
||||
mod db;
|
||||
mod file_urls;
|
||||
mod queries;
|
||||
mod row_decode;
|
||||
mod security;
|
||||
@@ -40,6 +41,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
let cli = parse_command()?;
|
||||
init_tracing();
|
||||
print_startup_credential();
|
||||
|
||||
let config = Config::load(cli.config.as_deref())?;
|
||||
if matches!(cli.command, Command::CheckConfig) {
|
||||
@@ -53,8 +55,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return Ok(());
|
||||
}
|
||||
info!(
|
||||
"RustPad version" = %config.asset_version,
|
||||
Copyright="@linuxiarz.pl Mateusz Gruszczyński",
|
||||
host = %config.host,
|
||||
port = config.port,
|
||||
database_kind = %database_kind_label(&config.database_url),
|
||||
@@ -65,6 +65,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
upload_max_size_bytes = config.upload_max_size_bytes,
|
||||
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
|
||||
file_cache_max_age_seconds = config.file_cache_max_age_seconds,
|
||||
files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"),
|
||||
registration_enabled = config.registration_enabled,
|
||||
account_confirmation_required = config.account_confirmation_required,
|
||||
share_confirmation_required = config.share_confirmation_required,
|
||||
@@ -73,7 +74,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
user_session_ttl_days = config.user_session_ttl_days,
|
||||
smtp_configured = config.smtp.is_some(),
|
||||
authorization_type = config.authorization_type.as_str(),
|
||||
asset_version = %config.asset_version,
|
||||
"configuration loaded"
|
||||
);
|
||||
if let Some(path) = config
|
||||
@@ -106,6 +106,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
storage,
|
||||
config.upload_max_size_bytes,
|
||||
config.file_cache_max_age_seconds,
|
||||
config.files_public_url.clone(),
|
||||
config.smtp.clone(),
|
||||
config.registration_enabled && config.ldap.is_none(),
|
||||
config.account_confirmation_required,
|
||||
@@ -152,7 +153,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let address = SocketAddr::new(config.host, config.port);
|
||||
let listener = TcpListener::bind(address).await?;
|
||||
|
||||
info!(%address, asset_version = %config.asset_version, "RustPad is running");
|
||||
info!(%address, "RustPad is running");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
@@ -160,6 +161,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_startup_credential() {
|
||||
eprintln!("\n{}\n", startup_credential());
|
||||
}
|
||||
|
||||
fn startup_credential() -> String {
|
||||
format!(
|
||||
"RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Command {
|
||||
Run,
|
||||
@@ -265,6 +277,18 @@ async fn shutdown_signal() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_tests {
|
||||
use super::startup_credential;
|
||||
|
||||
#[test]
|
||||
fn startup_credential_contains_product_identity() {
|
||||
let credential = startup_credential();
|
||||
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
|
||||
assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
let path = match db.kind() {
|
||||
DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"),
|
||||
|
||||
@@ -97,6 +97,7 @@ pub struct AppState {
|
||||
pub storage: crate::storage::Storage,
|
||||
pub upload_max_size_bytes: usize,
|
||||
pub file_cache_max_age_seconds: u64,
|
||||
pub files_public_url: Option<String>,
|
||||
pub smtp: Option<SmtpConfig>,
|
||||
pub registration_enabled: bool,
|
||||
pub account_confirmation_required: bool,
|
||||
@@ -119,6 +120,7 @@ impl AppState {
|
||||
storage: crate::storage::Storage,
|
||||
upload_max_size_bytes: usize,
|
||||
file_cache_max_age_seconds: u64,
|
||||
files_public_url: Option<String>,
|
||||
smtp: Option<SmtpConfig>,
|
||||
registration_enabled: bool,
|
||||
account_confirmation_required: bool,
|
||||
@@ -135,6 +137,7 @@ impl AppState {
|
||||
storage,
|
||||
upload_max_size_bytes,
|
||||
file_cache_max_age_seconds,
|
||||
files_public_url,
|
||||
smtp,
|
||||
registration_enabled,
|
||||
account_confirmation_required,
|
||||
|
||||
+4
-1
@@ -231,7 +231,10 @@ pub async fn delete_url_file(
|
||||
owner_id: i64,
|
||||
url: &str,
|
||||
) -> Result<(), StorageError> {
|
||||
let parts: Vec<&str> = url.trim_start_matches('/').split('/').collect();
|
||||
let Some(path) = crate::file_urls::canonical_file_path(url) else {
|
||||
return Ok(());
|
||||
};
|
||||
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
||||
if parts.len() != 3 || parts[0] != "f" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user