Compare commits
No commits in common. "main" and "notashelf/push-mytsqvppsvxu" have entirely different histories.
main
...
notashelf/
24 changed files with 331 additions and 333 deletions
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
238
.deny.toml
238
.deny.toml
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, .. }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"})))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: ®ex::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: ®ex::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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue