Compare commits

..

No commits in common. "main" and "notashelf/push-mytsqvppsvxu" have entirely different histories.

24 changed files with 331 additions and 333 deletions

View file

@ -6,17 +6,6 @@ rustflags = [
"-Clto",
"-Zvirtual-function-elimination",
"-Zlocation-detail=none",
# Configuration for these lints should be placed in `.clippy.toml` at the crate root.
"-Dwarnings",
]
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"link-args=-z stack-size=268435456",
"-C",
"target-feature=+atomics,+bulk-memory,+mutable-globals",
]

View file

@ -1,13 +1,74 @@
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
[bans]
multiple-versions = "allow" # TODO
wildcards = "allow"
skip = []
# This template contains all of the possible sections and their default values
# Note that all fields that take a lint level have these possible values:
# * deny - An error will be produced and the check will fail
# * warn - A warning will be produced, but the check will not fail
# * allow - No warning or error will be produced, though in some cases a note
# will be
# The values provided in this template are the default values that will be used
# when any section or field is not specified in your own configuration
# Root options
# The graph table configures how the dependency graph is constructed and thus
# which crates the checks are performed against
[graph]
# If 1 or more target triples (and optionally, target_features) are specified,
# only the specified targets will be checked when running `cargo deny check`.
# This means, if a particular package is only ever used as a target specific
# dependency, such as, for example, the `nix` crate only being used via the
# `target_family = "unix"` configuration, that only having windows targets in
# this list would mean the nix crate, as well as any of its exclusive
# dependencies not shared by any other crates, would be ignored, as the target
# list here is effectively saying which targets you are building for.
targets = [
# The triple can be any string, but only the target triples built in to
# rustc (as of 1.40) can be checked against actual config expressions
#"x86_64-unknown-linux-musl",
# You can also specify which target_features you promise are enabled for a
# particular target. target_features are currently not validated against
# the actual valid features supported by the target architecture.
#{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
]
# When creating the dependency graph used as the source of truth when checks are
# executed, this field can be used to prune crates from the graph, removing them
# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate
# is pruned from the graph, all of its dependencies will also be pruned unless
# they are connected to another crate in the graph that hasn't been pruned,
# so it should be used with care. The identifiers are [Package ID Specifications]
# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)
#exclude = []
# If true, metadata will be collected with `--all-features`. Note that this can't
# be toggled off if true, if you want to conditionally enable `--all-features` it
# is recommended to pass `--all-features` on the cmd line instead
all-features = false
# If true, metadata will be collected with `--no-default-features`. The same
# caveat with `all-features` applies
no-default-features = false
# If set, these feature will be enabled when collecting metadata. If `--features`
# is specified on the cmd line they will take precedence over this option.
#features = []
# The output table provides options for how/if diagnostics are outputted
[output]
# When outputting inclusion graphs in diagnostics that include features, this
# option can be used to specify the depth at which feature edges will be added.
# This option is included since the graphs can be quite large and the addition
# of features from the crate(s) to all of the graph roots can be far too verbose.
# This option can be overridden via `--feature-depth` on the cmd line
feature-depth = 1
# This section is considered when running `cargo deny check advisories`
# More documentation for the advisories section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
[advisories]
yanked = "deny"
unmaintained = "none"
# The path where the advisory databases are cloned/fetched into
#db-path = "$CARGO_HOME/advisory-dbs"
# The url(s) of the advisory databases to use
#db-urls = ["https://github.com/rustsec/advisory-db"]
# A list of advisory IDs to ignore. Note that ignored advisories will still
# output a note when they are encountered.
ignore = [
# Dioxus pulls a whole bunch of GTK3 dependencies that are all deprecated and
# marked insecure. Unfortunately, there doesn't seem to be a GTK4 migration
@ -21,12 +82,25 @@ ignore = [
{ id = "RUSTSEC-2024-0418", reason = "Used by Dioxus and there is no alternative!" },
{ id = "RUSTSEC-2024-0419", reason = "Used by Dioxus and there is no alternative!" },
{ id = "RUSTSEC-2024-0420", reason = "Used by Dioxus and there is no alternative!" },
]
#"RUSTSEC-0000-0000",
#{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
#"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
#{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
]
# If this is true, then cargo deny will use the git executable to fetch advisory database.
# If this is false, then it uses a built-in git library.
# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
# See Git Authentication for more information about setting up git authentication.
#git-fetch-with-cli = true
# This section is considered when running `cargo deny check licenses`
# More documentation for the licenses section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
[licenses]
unused-allowed-license = "deny"
private.ignore = true
# List of explicitly allowed licenses
# See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
allow = [
"Apache-2.0 WITH LLVM-exception",
"Apache-2.0",
@ -38,9 +112,147 @@ allow = [
"Unicode-3.0",
"BSD-2-Clause",
]
# The confidence threshold for detecting a license from license text.
# The higher the value, the more closely the license text must be to the
# canonical license text of a valid SPDX license file.
# [possible values: any between 0.0 and 1.0].
confidence-threshold = 0.8
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
# aren't accepted for every possible crate as with the normal allow list
exceptions = [
# Each entry is the crate and version constraint, and its specific allow
# list
#{ allow = ["Zlib"], crate = "adler32" },
]
# <https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html>
# Some crates don't have (easily) machine readable licensing information,
# adding a clarification entry for it allows you to manually specify the
# licensing information
#[[licenses.clarify]]
# The package spec the clarification applies to
#crate = "ring"
# The SPDX expression for the license requirements of the crate
#expression = "MIT AND ISC AND OpenSSL"
# One or more files in the crate's source used as the "source of truth" for
# the license expression. If the contents match, the clarification will be used
# when running the license check, otherwise the clarification will be ignored
# and the crate will be checked normally, which may produce warnings or errors
# depending on the rest of your configuration
#license-files = [
# Each entry is a crate relative path, and the (opaque) hash of its contents
#{ path = "LICENSE", hash = 0xbd0eed23 }
#]
[licenses.private]
# If true, ignores workspace crates that aren't published, or are only
# published to private registries.
# To see how to mark a crate as unpublished (to the official registry),
# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
ignore = false
# One or more private registries that you might publish crates to, if a crate
# is only published to private registries, and ignore is true, the crate will
# not have its license(s) checked
registries = [
#"https://sekretz.com/registry
]
# This section is considered when running `cargo deny check bans`.
# More documentation about the 'bans' section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
[bans]
# Lint level for when multiple versions of the same crate are detected
multiple-versions = "warn"
# Lint level for when a crate version requirement is `*`
wildcards = "allow"
# The graph highlighting used when creating dotgraphs for crates
# with multiple versions
# * lowest-version - The path to the lowest versioned duplicate is highlighted
# * simplest-path - The path to the version with the fewest edges is highlighted
# * all - Both lowest-version and simplest-path are used
highlight = "all"
# The default lint level for `default` features for crates that are members of
# the workspace that is being checked. This can be overridden by allowing/denying
# `default` on a crate-by-crate basis if desired.
workspace-default-features = "allow"
# The default lint level for `default` features for external crates that are not
# members of the workspace. This can be overridden by allowing/denying `default`
# on a crate-by-crate basis if desired.
external-default-features = "allow"
# List of crates that are allowed. Use with care!
allow = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
]
# If true, workspace members are automatically allowed even when using deny-by-default
# This is useful for organizations that want to deny all external dependencies by default
# but allow their own workspace crates without having to explicitly list them
allow-workspace = false
# List of crates to deny
deny = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
# Wrapper crates can optionally be specified to allow the crate when it
# is a direct dependency of the otherwise banned crate
#{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
]
# List of features to allow/deny
# Each entry the name of a crate and a version range. If version is
# not specified, all versions will be matched.
#[[bans.features]]
#crate = "reqwest"
# Features to not allow
#deny = ["json"]
# Features to allow
#allow = [
# "rustls",
# "__rustls",
# "__tls",
# "hyper-rustls",
# "rustls",
# "rustls-pemfile",
# "rustls-tls-webpki-roots",
# "tokio-rustls",
# "webpki-roots",
#]
# If true, the allowed features must exactly match the enabled feature set. If
# this is set there is no point setting `deny`
#exact = true
# Certain crates/versions that will be skipped when doing duplicate detection.
skip = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
]
# Similarly to `skip` allows you to skip certain crates during duplicate
# detection. Unlike skip, it also includes the entire tree of transitive
# dependencies starting at the specified crate, up to a certain depth, which is
# by default infinite.
skip-tree = [
#"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
#{ crate = "ansi_term@0.11.0", depth = 20 },
]
# This section is considered when running `cargo deny check sources`.
# More documentation about the 'sources' section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
[sources]
unknown-registry = "deny"
unknown-git = "deny"
# Lint level for what to happen when a crate from a crate registry that is not
# in the allow list is encountered
unknown-registry = "warn"
# Lint level for what to happen when a crate from a git repository that is not
# in the allow list is encountered
unknown-git = "warn"
# List of URLs for allowed crate registries. Defaults to the crates.io index
# if not specified. If it is specified but empty, no registries are allowed.
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# List of URLs for allowed Git repositories
allow-git = []
[sources.allow-org]
# github.com organizations to allow git sources for
github = []
# gitlab.com organizations to allow git sources for
gitlab = []
# bitbucket.org organizations to allow git sources for
bitbucket = []

View file

@ -392,13 +392,7 @@ pub async fn cleanup_orphaned_thumbnails(
if thumbnail_dir.exists() {
let entries = std::fs::read_dir(thumbnail_dir)?;
for entry in entries.filter_map(|e| {
e.map_err(|err| {
warn!(error = %err, "failed to read thumbnail directory entry");
err
})
.ok()
}) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& !known_ids.contains(stem)

View file

@ -271,9 +271,7 @@ pub async fn scan_directory_with_options(
if let Some(p) = progress {
p.record_error(msg.clone());
}
if errors.len() < MAX_STORED_ERRORS {
errors.push(msg);
}
},
}
}

View file

@ -3721,20 +3721,8 @@ impl StorageBackend for PostgresBackend {
.map(|p| p.to_string_lossy().to_string());
let track_index = subtitle
.track_index
.map(|i| {
i32::try_from(i).map_err(|_| {
PinakesError::InvalidOperation(format!(
"subtitle track_index {i} exceeds i32 range"
))
})
})
.transpose()?;
let offset_ms = i32::try_from(subtitle.offset_ms).map_err(|_| {
PinakesError::InvalidOperation(format!(
"subtitle offset_ms {} exceeds i32 range",
subtitle.offset_ms
))
})?;
.map(|i| i32::try_from(i).unwrap_or(i32::MAX));
let offset_ms = i32::try_from(subtitle.offset_ms).unwrap_or(i32::MAX);
client
.execute(
"INSERT INTO subtitles (id, media_id, language, format, file_path, \
@ -3821,11 +3809,7 @@ impl StorageBackend for PostgresBackend {
.get()
.await
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
let offset = i32::try_from(offset_ms).map_err(|_| {
PinakesError::InvalidOperation(format!(
"subtitle offset_ms {offset_ms} exceeds i32 range"
))
})?;
let offset = i32::try_from(offset_ms).unwrap_or(i32::MAX);
client
.execute("UPDATE subtitles SET offset_ms = $1 WHERE id = $2", &[
&offset, &id,

View file

@ -28,8 +28,7 @@ impl TempFileGuard {
impl Drop for TempFileGuard {
fn drop(&mut self) {
if self.0.exists()
&& let Err(e) = std::fs::remove_file(&self.0)
{
&& let Err(e) = std::fs::remove_file(&self.0) {
warn!("failed to clean up temp file {}: {e}", self.0.display());
}
}

View file

@ -134,10 +134,7 @@ impl SchemaValidator {
}
/// Recursively validate a [`UiElement`] subtree.
pub(crate) fn validate_element(
element: &UiElement,
errors: &mut Vec<String>,
) {
pub fn validate_element(element: &UiElement, errors: &mut Vec<String>) {
match element {
UiElement::Container { children, .. }
| UiElement::Grid { children, .. }

View file

@ -16,16 +16,15 @@ pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String {
let mut best: Option<&PathBuf> = None;
for root in roots {
if full_path.starts_with(root) {
let is_longer =
best.is_none_or(|b| root.components().count() > b.components().count());
let is_longer = best
.is_none_or(|b| root.components().count() > b.components().count());
if is_longer {
best = Some(root);
}
}
}
if let Some(root) = best
&& let Ok(rel) = full_path.strip_prefix(root)
{
&& let Ok(rel) = full_path.strip_prefix(root) {
// Normalise to forward slashes on all platforms.
return rel
.components()

View file

@ -1,14 +1,7 @@
use pinakes_core::model::Pagination;
use serde::{Deserialize, Serialize};
use super::media::MediaResponse;
/// Maximum offset accepted from clients. Prevents pathologically large OFFSET
/// values that cause expensive sequential scans in the database.
pub const MAX_OFFSET: u64 = 10_000_000;
/// Maximum page size accepted from most listing endpoints.
pub const MAX_LIMIT: u64 = 1000;
#[derive(Debug, Deserialize)]
pub struct SearchParams {
pub q: String,
@ -17,17 +10,6 @@ pub struct SearchParams {
pub limit: Option<u64>,
}
impl SearchParams {
#[must_use]
pub fn to_pagination(&self) -> Pagination {
Pagination::new(
self.offset.unwrap_or(0).min(MAX_OFFSET),
self.limit.unwrap_or(50).min(MAX_LIMIT),
None,
)
}
}
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub items: Vec<MediaResponse>,
@ -43,17 +25,6 @@ pub struct SearchRequestBody {
pub limit: Option<u64>,
}
impl SearchRequestBody {
#[must_use]
pub fn to_pagination(&self) -> Pagination {
Pagination::new(
self.offset.unwrap_or(0).min(MAX_OFFSET),
self.limit.unwrap_or(50).min(MAX_LIMIT),
None,
)
}
}
// Pagination
#[derive(Debug, Deserialize)]
pub struct PaginationParams {
@ -61,14 +32,3 @@ pub struct PaginationParams {
pub limit: Option<u64>,
pub sort: Option<String>,
}
impl PaginationParams {
#[must_use]
pub fn to_pagination(&self) -> Pagination {
Pagination::new(
self.offset.unwrap_or(0).min(MAX_OFFSET),
self.limit.unwrap_or(50).min(MAX_LIMIT),
self.sort.clone(),
)
}
}

View file

@ -2,6 +2,7 @@ use axum::{
Json,
extract::{Query, State},
};
use pinakes_core::model::Pagination;
use crate::{
dto::{AuditEntryResponse, PaginationParams},
@ -13,7 +14,11 @@ pub async fn list_audit(
State(state): State<AppState>,
Query(params): Query<PaginationParams>,
) -> Result<Json<Vec<AuditEntryResponse>>, ApiError> {
let pagination = params.to_pagination();
let pagination = Pagination::new(
params.offset.unwrap_or(0),
params.limit.unwrap_or(50).min(1000),
None,
);
let entries = state.storage.list_audit_entries(None, &pagination).await?;
Ok(Json(
entries.into_iter().map(AuditEntryResponse::from).collect(),

View file

@ -31,9 +31,7 @@ pub async fn create_backup(
let bytes = tokio::fs::read(&backup_path)
.await
.map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?;
if let Err(e) = tokio::fs::remove_dir_all(&backup_dir).await {
tracing::warn!(path = %backup_dir.display(), error = %e, "failed to clean up backup temp dir");
}
let _ = tokio::fs::remove_dir_all(&backup_dir).await;
let disposition = format!("attachment; filename=\"{filename}\"");
Ok(

View file

@ -22,7 +22,7 @@ use uuid::Uuid;
use crate::{
auth::resolve_user_id,
dto::{MAX_OFFSET, MediaResponse},
dto::MediaResponse,
error::ApiError,
state::AppState,
};
@ -177,7 +177,7 @@ pub async fn list_books(
Query(query): Query<SearchBooksQuery>,
) -> Result<impl IntoResponse, ApiError> {
let pagination = Pagination {
offset: query.offset.min(MAX_OFFSET),
offset: query.offset,
limit: query.limit.min(1000),
sort: None,
};

View file

@ -26,8 +26,6 @@ pub async fn vacuum_database(
pub async fn clear_database(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
tracing::error!("clear_database: all data is being wiped by admin request");
state.storage.clear_all_data().await?;
tracing::error!("clear_database: all data wiped successfully");
Ok(Json(serde_json::json!({"status": "ok"})))
}

View file

@ -42,13 +42,6 @@ pub async fn batch_enrich(
State(state): State<AppState>,
Json(req): Json<BatchDeleteRequest>, // Reuse: has media_ids field
) -> Result<Json<serde_json::Value>, ApiError> {
if req.media_ids.is_empty() || req.media_ids.len() > 1000 {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"media_ids must contain 1-1000 items".into(),
),
));
}
let media_ids: Vec<MediaId> =
req.media_ids.into_iter().map(MediaId).collect();
let job_id = state

View file

@ -2,7 +2,10 @@ use axum::{
Json,
extract::{Path, Query, State},
};
use pinakes_core::{model::MediaId, storage::DynStorageBackend};
use pinakes_core::{
model::{MediaId, Pagination},
storage::DynStorageBackend,
};
use uuid::Uuid;
use crate::{
@ -37,24 +40,6 @@ use crate::{
state::AppState,
};
/// Validates that a destination path is absolute and within a configured root.
fn validate_destination_path(
destination: &std::path::Path,
roots: &[std::path::PathBuf],
) -> Result<(), ApiError> {
if !destination.is_absolute() {
return Err(ApiError::bad_request(
"destination must be an absolute path",
));
}
if !roots.iter().any(|root| destination.starts_with(root)) {
return Err(ApiError::bad_request(
"destination must be within a configured library root",
));
}
Ok(())
}
/// Apply tags and add to collection after a successful import.
/// Shared logic used by `import_with_options`, `batch_import`, and
/// `import_directory_endpoint`.
@ -129,7 +114,11 @@ pub async fn list_media(
State(state): State<AppState>,
Query(params): Query<PaginationParams>,
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
let pagination = params.to_pagination();
let pagination = Pagination::new(
params.offset.unwrap_or(0),
params.limit.unwrap_or(50).min(1000),
params.sort,
);
let items = state.storage.list_media(&pagination).await?;
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
@ -398,12 +387,6 @@ pub async fn import_with_options(
State(state): State<AppState>,
Json(req): Json<ImportWithOptionsRequest>,
) -> Result<Json<ImportResponse>, ApiError> {
if req.tag_ids.as_ref().is_some_and(|v| v.len() > 100) {
return Err(ApiError::bad_request("tag_ids must not exceed 100 items"));
}
if req.new_tags.as_ref().is_some_and(|v| v.len() > 100) {
return Err(ApiError::bad_request("new_tags must not exceed 100 items"));
}
let result = pinakes_core::import::import_file(
&state.storage,
&req.path,
@ -432,12 +415,6 @@ pub async fn batch_import(
State(state): State<AppState>,
Json(req): Json<BatchImportRequest>,
) -> Result<Json<BatchImportResponse>, ApiError> {
if req.tag_ids.as_ref().is_some_and(|v| v.len() > 100) {
return Err(ApiError::bad_request("tag_ids must not exceed 100 items"));
}
if req.new_tags.as_ref().is_some_and(|v| v.len() > 100) {
return Err(ApiError::bad_request("new_tags must not exceed 100 items"));
}
if req.paths.len() > 10_000 {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
@ -799,18 +776,20 @@ pub async fn batch_delete(
let media_ids: Vec<MediaId> =
req.media_ids.iter().map(|id| MediaId(*id)).collect();
// Record a single audit entry before delete to avoid FK constraint
// violations. One entry for the whole batch is sufficient.
// Record audit entries BEFORE delete to avoid FK constraint violation.
// Use None for media_id since they'll be deleted; include ID in details.
for id in &media_ids {
if let Err(e) = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::Deleted,
Some(format!("batch delete: {} items", media_ids.len())),
Some(format!("batch delete: media_id={}", id.0)),
)
.await
{
tracing::warn!(error = %e, "failed to record audit entry");
}
}
match state.storage.batch_delete_media(&media_ids).await {
Ok(count) => {
@ -945,15 +924,6 @@ pub async fn rename_media(
Path(id): Path<Uuid>,
Json(req): Json<RenameMediaRequest>,
) -> Result<Json<MediaResponse>, ApiError> {
let name_len = req.new_name.chars().count();
if name_len == 0 || name_len > 255 {
return Err(ApiError::bad_request("new_name must be 1-255 characters"));
}
if req.new_name.contains('\0') || req.new_name.contains('/') {
return Err(ApiError::bad_request(
"new_name must not contain null bytes or path separators",
));
}
let media_id = MediaId(id);
// Perform the rename
@ -997,8 +967,6 @@ pub async fn move_media_endpoint(
Path(id): Path<Uuid>,
Json(req): Json<MoveMediaRequest>,
) -> Result<Json<MediaResponse>, ApiError> {
let roots = state.config.read().await.directories.roots.clone();
validate_destination_path(&req.destination, &roots)?;
let media_id = MediaId(id);
// Perform the move
@ -1052,8 +1020,6 @@ pub async fn batch_move_media(
),
));
}
let roots = state.config.read().await.directories.roots.clone();
validate_destination_path(&req.destination, &roots)?;
let media_ids: Vec<MediaId> =
req.media_ids.iter().map(|id| MediaId(*id)).collect();
@ -1064,27 +1030,17 @@ pub async fn batch_move_media(
.await
{
Ok(results) => {
// Record sync changes for each moved item. Derive the new path from
// the destination and old filename to avoid N extra get_media calls.
// Record sync changes for each moved item
for (media_id, old_path) in &results {
let Some(file_name) =
std::path::Path::new(old_path.as_str()).file_name()
else {
tracing::warn!(
old_path = %old_path,
"skipping sync log entry: no filename in old_path"
);
continue;
};
let new_path = req.destination.join(file_name);
if let Ok(item) = state.storage.get_media(*media_id).await {
let change = pinakes_core::sync::SyncLogEntry {
id: uuid::Uuid::now_v7(),
sequence: 0,
change_type: pinakes_core::sync::SyncChangeType::Moved,
media_id: Some(*media_id),
path: new_path.to_string_lossy().to_string(),
content_hash: None,
file_size: None,
path: item.path.to_string_lossy().to_string(),
content_hash: Some(item.content_hash.clone()),
file_size: Some(item.file_size),
metadata_json: Some(
serde_json::json!({ "old_path": old_path }).to_string(),
),
@ -1095,6 +1051,7 @@ pub async fn batch_move_media(
tracing::warn!(error = %e, "failed to record sync change");
}
}
}
Ok(Json(BatchOperationResponse {
processed: results.len(),
@ -1207,7 +1164,11 @@ pub async fn list_trash(
State(state): State<AppState>,
Query(params): Query<PaginationParams>,
) -> Result<Json<TrashResponse>, ApiError> {
let pagination = params.to_pagination();
let pagination = Pagination::new(
params.offset.unwrap_or(0),
params.limit.unwrap_or(50).min(1000),
params.sort,
);
let items = state.storage.list_trash(&pagination).await?;
let count = state.storage.count_trash().await?;

View file

@ -152,14 +152,6 @@ pub async fn get_map_photos(
State(state): State<AppState>,
Query(query): Query<MapQuery>,
) -> Result<impl IntoResponse, ApiError> {
let valid_lat = |v: f64| v.is_finite() && (-90.0..=90.0).contains(&v);
let valid_lon = |v: f64| v.is_finite() && (-180.0..=180.0).contains(&v);
if !valid_lat(query.lat1) || !valid_lat(query.lat2) {
return Err(ApiError::bad_request("latitude must be in [-90, 90]"));
}
if !valid_lon(query.lon1) || !valid_lon(query.lon2) {
return Err(ApiError::bad_request("longitude must be in [-180, 180]"));
}
// Validate bounding box
let min_lat = query.lat1.min(query.lat2);
let max_lat = query.lat1.max(query.lat2);

View file

@ -22,43 +22,10 @@ pub struct SavedSearchResponse {
pub created_at: chrono::DateTime<chrono::Utc>,
}
const VALID_SORT_ORDERS: &[&str] = &[
"date_asc",
"date_desc",
"name_asc",
"name_desc",
"size_asc",
"size_desc",
];
pub async fn create_saved_search(
State(state): State<AppState>,
Json(req): Json<CreateSavedSearchRequest>,
) -> Result<Json<SavedSearchResponse>, ApiError> {
let name_len = req.name.chars().count();
if name_len == 0 || name_len > 255 {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"name must be 1-255 characters".into(),
),
));
}
if req.query.is_empty() || req.query.len() > 2048 {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"query must be 1-2048 bytes".into(),
),
));
}
if let Some(ref sort) = req.sort_order
&& !VALID_SORT_ORDERS.contains(&sort.as_str()) {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(format!(
"sort_order must be one of: {}",
VALID_SORT_ORDERS.join(", ")
)),
));
}
let id = uuid::Uuid::now_v7();
state
.storage

View file

@ -2,7 +2,10 @@ use axum::{
Json,
extract::{Query, State},
};
use pinakes_core::search::{SearchRequest, SortOrder, parse_search_query};
use pinakes_core::{
model::Pagination,
search::{SearchRequest, SortOrder, parse_search_query},
};
use crate::{
dto::{MediaResponse, SearchParams, SearchRequestBody, SearchResponse},
@ -40,7 +43,11 @@ pub async fn search(
let request = SearchRequest {
query,
sort,
pagination: params.to_pagination(),
pagination: Pagination::new(
params.offset.unwrap_or(0),
params.limit.unwrap_or(50).min(1000),
None,
),
};
let results = state.storage.search(&request).await?;
@ -74,7 +81,11 @@ pub async fn search_post(
let request = SearchRequest {
query,
sort,
pagination: body.to_pagination(),
pagination: Pagination::new(
body.offset.unwrap_or(0),
body.limit.unwrap_or(50).min(1000),
None,
),
};
let results = state.storage.search(&request).await?;

View file

@ -207,7 +207,11 @@ pub async fn list_outgoing(
Query(params): Query<PaginationParams>,
) -> ApiResult<Json<Vec<ShareResponse>>> {
let user_id = resolve_user_id(&state.storage, &username).await?;
let pagination = params.to_pagination();
let pagination = Pagination {
offset: params.offset.unwrap_or(0),
limit: params.limit.unwrap_or(50).min(1000),
sort: params.sort,
};
let shares = state
.storage
@ -226,7 +230,11 @@ pub async fn list_incoming(
Query(params): Query<PaginationParams>,
) -> ApiResult<Json<Vec<ShareResponse>>> {
let user_id = resolve_user_id(&state.storage, &username).await?;
let pagination = params.to_pagination();
let pagination = Pagination {
offset: params.offset.unwrap_or(0),
limit: params.limit.unwrap_or(50).min(1000),
sort: params.sort,
};
let shares = state
.storage
@ -398,9 +406,6 @@ pub async fn batch_delete(
Extension(username): Extension<String>,
Json(req): Json<BatchDeleteSharesRequest>,
) -> ApiResult<Json<serde_json::Value>> {
if req.share_ids.is_empty() || req.share_ids.len() > 100 {
return Err(ApiError::bad_request("share_ids must contain 1-100 items"));
}
let user_id = resolve_user_id(&state.storage, &username).await?;
let share_ids: Vec<ShareId> =
req.share_ids.into_iter().map(ShareId).collect();
@ -619,7 +624,11 @@ pub async fn get_activity(
));
}
let pagination = params.to_pagination();
let pagination = Pagination {
offset: params.offset.unwrap_or(0),
limit: params.limit.unwrap_or(50).min(1000),
sort: params.sort,
};
let activity = state
.storage

View file

@ -40,17 +40,6 @@ pub async fn rate_media(
),
));
}
if req
.review_text
.as_ref()
.is_some_and(|t| t.chars().count() > 10_000)
{
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"review_text must not exceed 10000 characters".into(),
),
));
}
let user_id = resolve_user_id(&state.storage, &username).await?;
let rating = state
.storage
@ -150,13 +139,6 @@ pub async fn create_share_link(
Extension(username): Extension<String>,
Json(req): Json<CreateShareLinkRequest>,
) -> Result<Json<ShareLinkResponse>, ApiError> {
if req.password.as_ref().is_some_and(|p| p.len() > 1024) {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"password must not exceed 1024 bytes".into(),
),
));
}
let user_id = resolve_user_id(&state.storage, &username).await?;
let token = uuid::Uuid::now_v7().to_string().replace('-', "");
let password_hash = match req.password.as_ref() {
@ -196,13 +178,6 @@ pub async fn access_shared_media(
Path(token): Path<String>,
Query(query): Query<ShareLinkQuery>,
) -> Result<Json<MediaResponse>, ApiError> {
if query.password.as_ref().is_some_and(|p| p.len() > 1024) {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"password must not exceed 1024 bytes".into(),
),
));
}
let link = state.storage.get_share_link(&token).await?;
// Check expiration
if let Some(expires) = link.expires_at

View file

@ -47,13 +47,6 @@ pub async fn add_subtitle(
),
));
}
if req
.language
.as_ref()
.is_some_and(|l| l.is_empty() || l.len() > 64)
{
return Err(ApiError::bad_request("language must be 1-64 bytes"));
}
let subtitle = Subtitle {
id: Uuid::now_v7(),
media_id: MediaId(id),

View file

@ -16,9 +16,6 @@ pub async fn start_transcode(
Path(id): Path<Uuid>,
Json(req): Json<CreateTranscodeRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
if req.profile.is_empty() || req.profile.len() > 255 {
return Err(ApiError::bad_request("profile must be 1-255 bytes"));
}
let job_id = state
.job_queue
.submit(pinakes_core::jobs::JobKind::Transcode {

View file

@ -161,28 +161,12 @@ pub async fn get_user_libraries(
))
}
fn validate_root_path(path: &str) -> Result<(), ApiError> {
if path.is_empty() || path.len() > 4096 {
return Err(ApiError::bad_request("root_path must be 1-4096 bytes"));
}
if !path.starts_with('/') {
return Err(ApiError::bad_request("root_path must be an absolute path"));
}
if path.split('/').any(|segment| segment == "..") {
return Err(ApiError::bad_request(
"root_path must not contain '..' traversal components",
));
}
Ok(())
}
/// Grant library access to a user (admin only)
pub async fn grant_library_access(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<GrantLibraryAccessRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
validate_root_path(&req.root_path)?;
let user_id: UserId =
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
@ -207,7 +191,6 @@ pub async fn revoke_library_access(
Path(id): Path<String>,
Json(req): Json<RevokeLibraryAccessRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
validate_root_path(&req.root_path)?;
let user_id: UserId =
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(

View file

@ -235,24 +235,13 @@ fn render_markdown(text: &str) -> String {
/// Convert wikilinks [[target]] and [[target|display]] to styled HTML links.
/// Uses a special URL scheme that can be intercepted by click handlers.
///
/// # Panics
///
/// Never panics because the regex patterns are hardcoded and syntactically
/// valid.
#[expect(clippy::expect_used)]
fn convert_wikilinks(text: &str) -> String {
use regex::Regex;
// Match embeds ![[target]] first, convert to a placeholder image/embed span
let embed_re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
.expect("invalid regex pattern for wikilink embeds");
let embed_re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap();
let text = embed_re.replace_all(text, |caps: &regex::Captures| {
let target = caps
.get(1)
.expect("capture group 1 always exists for wikilink embeds")
.as_str()
.trim();
let target = caps.get(1).unwrap().as_str().trim();
let alt = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target);
format!(
"<span class=\"wikilink-embed\" data-target=\"{}\" title=\"Embed: \
@ -264,14 +253,9 @@ fn convert_wikilinks(text: &str) -> String {
});
// Match wikilinks [[target]] or [[target|display]]
let wikilink_re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
.expect("invalid regex pattern for wikilinks");
let wikilink_re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap();
let text = wikilink_re.replace_all(&text, |caps: &regex::Captures| {
let target = caps
.get(1)
.expect("capture group 1 always exists for wikilinks")
.as_str()
.trim();
let target = caps.get(1).unwrap().as_str().trim();
let display = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target);
// Create a styled link that uses a special pseudo-protocol scheme
// This makes it easier to intercept clicks via JavaScript