Files
yaya 18f9830800 Complete unified Cloud file apps and polish mobile UX
Connect installed Nextcloud and Immich accounts automatically after app setup, integrate their folders and indexed files into Cloud, and support private uploads, permission-checked deletion, and explicit sharing snapshots. Persist disconnect choices and retain advanced account setup.

Refine floating navigation, app cards, container icons, scroll fades, private-key dialogs, and federation graph request visibility across desktop and mobile.

Validation: cargo check and UI typecheck passed; 38 focused UI tests, 25 adapter tests including disposable Nextcloud/Immich integration, and persistent opt-out test passed. Chromium, Firefox, and WebKit desktop/mobile workflows passed. Requires UI/UX UAT approval from the maintainer before release.
2026-11-10 02:46:29 +00:00

866 lines
36 KiB
Rust

use super::{
InstalledTarget, PreviewKind, SourceAlbum, SourceIdentity, SourceItem, SourceKind, SourcePage,
};
use anyhow::{ensure, Context, Result};
use reqwest::{header::HeaderValue, Method};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
/// The API key stays in a sensitive header and is never part of an item or URL.
pub(crate) struct ImmichAdapter {
target: InstalledTarget,
identity: SourceIdentity,
key: HeaderValue,
client: reqwest::Client,
}
impl ImmichAdapter {
/// Setup derives the account from the supplied key, never from user input.
pub async fn connect(target: InstalledTarget, key: &str) -> Result<Self> {
tokio::time::timeout(std::time::Duration::from_secs(30), async {
let identity = SourceIdentity {
connection_id: uuid::Uuid::new_v4().to_string(),
kind: SourceKind::Immich,
installation: target.installation.clone(),
account_id: uuid::Uuid::nil().to_string(),
};
let mut adapter = Self::new(target, identity, key)?;
let user = adapter
.json(Method::GET, &["api", "users", "me"], None)
.await?;
let account = user
.get("id")
.and_then(Value::as_str)
.context("Immich account identity unavailable")?;
uuid::Uuid::parse_str(account).context("Invalid Immich account identity")?;
adapter.identity.account_id = account.into();
let permissions = adapter.permissions().await?;
ensure!(
permissions.iter().any(|v| v == "asset.read" || v == "all"),
"Immich key needs asset.read permission"
);
Ok(adapter)
})
.await
.context("Immich account verification timed out")?
}
pub fn identity(&self) -> &SourceIdentity {
&self.identity
}
pub fn new(target: InstalledTarget, identity: SourceIdentity, key: &str) -> Result<Self> {
ensure!(
target.kind == SourceKind::Immich && identity.kind == SourceKind::Immich,
"Cloud source type mismatch"
);
ensure!(
identity.installation == target.installation,
"Cloud source installation changed; reconnect this account"
);
ensure!(
uuid::Uuid::parse_str(&identity.connection_id).is_ok()
&& uuid::Uuid::parse_str(&identity.account_id).is_ok(),
"Invalid Cloud source identity"
);
ensure!(
!key.is_empty() && key.len() <= 4096,
"Invalid Immich API key"
);
let mut key = HeaderValue::from_str(key).context("Invalid Immich API key format")?;
key.set_sensitive(true);
Ok(Self {
target,
identity,
key,
client: InstalledTarget::client()?,
})
}
async fn verified_asset(&self, request: &super::handles::StreamRequest) -> Result<Value> {
uuid::Uuid::parse_str(&request.upstream_id).context("Invalid Immich item identity")?;
ensure!(
request.locator.is_none(),
"Immich does not accept a file path"
);
let item = self
.json(Method::GET, &["api", "assets", &request.upstream_id], None)
.await?;
ensure!(
item.get("id").and_then(Value::as_str) == Some(request.upstream_id.as_str())
&& item.get("isTrashed").and_then(Value::as_bool) != Some(true),
"Immich item unavailable"
);
ensure!(
asset_revision(&item)? == request.revision,
"Immich item changed; refresh this source"
);
Ok(item)
}
pub(super) async fn delete(&self, request: &super::handles::StreamRequest) -> Result<()> {
let permissions = self.permissions().await?;
ensure!(
permissions
.iter()
.any(|p| p == "asset.delete" || p == "all"),
"Immich key cannot delete assets"
);
let item = self.verified_asset(request).await?;
ensure!(
item.get("ownerId").and_then(Value::as_str) == Some(self.identity.account_id.as_str()),
"Only your own assets can be removed"
);
self.json(
Method::DELETE,
&["api", "assets"],
Some(json!({"ids": [request.upstream_id], "force": false})),
)
.await?;
Ok(())
}
pub(super) async fn upload(
&self,
folder: &[String],
name: &str,
file: tokio::fs::File,
length: u64,
) -> Result<()> {
ensure!(
folder.is_empty(),
"Upload Immich assets into its main folder"
);
let permissions = self.permissions().await?;
ensure!(
permissions
.iter()
.any(|p| p == "asset.upload" || p == "all"),
"Immich key cannot upload assets"
);
let now = chrono::Utc::now().to_rfc3339();
let part = reqwest::multipart::Part::stream_with_length(
reqwest::Body::wrap_stream(tokio_util::io::ReaderStream::new(file)),
length,
)
.file_name(name.to_owned());
let form = reqwest::multipart::Form::new()
.text("deviceAssetId", uuid::Uuid::new_v4().to_string())
.text("deviceId", "archipelago-cloud")
.text("fileCreatedAt", now.clone())
.text("fileModifiedAt", now)
.part("assetData", part);
let response = InstalledTarget::stream_client()?
.post(self.target.url(&["api", "assets"])?)
.header("x-api-key", self.key.clone())
.multipart(form)
.send()
.await?;
ensure!(
response.status().is_success(),
"Immich upload refused (HTTP {})",
response.status().as_u16()
);
Ok(())
}
pub(super) async fn open_stream(
&self,
request: &super::handles::StreamRequest,
headers: &hyper::HeaderMap,
) -> Result<(super::stream::Representation, hyper::Response<hyper::Body>)> {
tokio::time::timeout(std::time::Duration::from_secs(30), async {
let permissions = self.permissions().await?;
let required = if request.download {
"asset.download"
} else {
"asset.view"
};
ensure!(
permissions.iter().any(|v| v == required || v == "all"),
"Immich key lacks permission for these bytes"
);
let item = self.verified_asset(request).await?;
let filename = item
.get("originalFileName")
.and_then(Value::as_str)
.context("Immich filename unavailable")?;
let preview_kind = asset_preview_kind(&item);
let mut path = vec!["api", "assets", request.upstream_id.as_str()];
if request.download {
path.push("original");
} else {
match preview_kind.context("Immich asset has no supported preview")? {
PreviewKind::Video => path.extend(["video", "playback"]),
PreviewKind::Image => path.push("thumbnail"),
}
}
let mut url = self.target.url(&path)?;
if !request.download && preview_kind == Some(PreviewKind::Image) {
url.query_pairs_mut().append_pair("size", "preview");
}
if request.download || preview_kind == Some(PreviewKind::Image) {
url.query_pairs_mut().append_pair("edited", "false");
}
let client = InstalledTarget::stream_client()?;
let head = client
.head(url.clone())
.header("x-api-key", self.key.clone())
.header("Accept-Encoding", "identity")
.send()
.await?;
let representation = super::stream::Representation::from_head(&head)?;
let range = super::stream::selected_range(headers, representation.length)?;
let upstream = representation
.request(
client
.get(url)
.header("x-api-key", self.key.clone())
.header("Accept-Encoding", "identity"),
range,
)?
.send()
.await?;
let mime = upstream
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_owned();
if !request.download {
ensure!(
match preview_kind {
Some(PreviewKind::Video) => matches!(
mime.split(';').next().unwrap_or("").trim(),
"video/mp4" | "video/webm" | "video/ogg"
),
Some(PreviewKind::Image) => matches!(
mime.as_str(),
"image/jpeg" | "image/png" | "image/webp" | "image/avif"
),
None => false,
},
"Immich preview is not a supported media representation"
);
}
self.verified_asset(request).await?;
let response = super::stream::response(
upstream,
representation.clone(),
range,
&mime,
filename,
request.download,
)?;
Ok((representation, response))
})
.await
.context("Immich stream preparation timed out")?
}
async fn json(&self, method: Method, path: &[&str], body: Option<Value>) -> Result<Value> {
let mut request = self
.client
.request(method, self.target.url(path)?)
.header("x-api-key", self.key.clone());
if let Some(body) = body {
request = request.json(&body)
}
let mut response = request
.send()
.await
.context("Cloud source request failed")?;
ensure!(
response.status().is_success(),
"Immich refused request (HTTP {})",
response.status().as_u16()
);
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await? {
ensure!(
bytes.len() + chunk.len() <= 4 * 1024 * 1024,
"Immich response exceeds source listing limit"
);
bytes.extend_from_slice(&chunk);
}
if bytes.is_empty() {
return Ok(Value::Null);
}
serde_json::from_slice(&bytes).context("Invalid Immich response")
}
async fn permissions(&self) -> Result<Vec<String>> {
let version = self
.json(Method::GET, &["api", "server", "version"], None)
.await?;
ensure!(
version.get("major").and_then(Value::as_u64) == Some(2)
&& version.get("minor").and_then(Value::as_u64) == Some(7)
&& version.get("patch").and_then(Value::as_u64) == Some(4),
"This Cloud adapter requires the verified Immich 2.7.4 API"
);
let user = self
.json(Method::GET, &["api", "users", "me"], None)
.await?;
ensure!(
user.get("id").and_then(Value::as_str) == Some(self.identity.account_id.as_str()),
"Cloud source account changed; reconnect this account"
);
let info = self
.json(Method::GET, &["api", "api-keys", "me"], None)
.await?;
let permissions = info
.get("permissions")
.and_then(Value::as_array)
.context("Immich key permissions unavailable")?;
permissions
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.context("Invalid Immich key permission")
})
.collect()
}
#[cfg(test)]
pub async fn list(&self, page: u32) -> Result<SourcePage> {
self.list_album(page, None).await
}
pub async fn list_album(&self, page: u32, album_id: Option<&str>) -> Result<SourcePage> {
tokio::time::timeout(
std::time::Duration::from_secs(30),
self.list_inner(page, album_id),
)
.await
.context("Immich listing timed out")?
}
async fn list_inner(&self, page: u32, album_id: Option<&str>) -> Result<SourcePage> {
ensure!((1..=100_000).contains(&page), "Invalid Cloud source page");
let album_id = album_id
.map(uuid::Uuid::parse_str)
.transpose()
.context("Invalid Immich album identity")?
.map(|id| id.to_string());
let permissions = self.permissions().await?;
let has = |permission: &str| permissions.iter().any(|v| v == permission || v == "all");
ensure!(has("asset.read"), "Immich key needs asset.read permission");
// Do not make album.read a new prerequisite for existing asset-only keys.
// When available, the upstream account-authorized inventory is the only
// accepted source of selectable album IDs, refreshed on every page.
let albums = if has("album.read") {
Some(parse_albums(
self.json(Method::GET, &["api", "albums"], None).await?,
)?)
} else {
None
};
if let Some(id) = &album_id {
ensure!(
albums
.as_ref()
.is_some_and(|items| items.iter().any(|item| &item.id == id)),
"Immich album is unavailable or the key lacks album.read permission"
);
}
let mut query = json!({"page":page,"size":100,"withDeleted":false,"withExif":true});
if let Some(id) = &album_id {
query["albumIds"] = json!([id]);
}
let response = self
.json(Method::POST, &["api", "search", "metadata"], Some(query))
.await?;
let assets = response
.get("assets")
.context("Immich listing missing assets")?;
let items = assets
.get("items")
.and_then(Value::as_array)
.context("Immich listing missing items")?;
ensure!(items.len() <= 100, "Immich page exceeds requested limit");
let mut output = Vec::with_capacity(items.len());
for item in items {
if item.get("isTrashed").and_then(Value::as_bool) == Some(true) {
continue;
}
let id = item
.get("id")
.and_then(Value::as_str)
.context("Immich asset missing identity")?;
ensure!(
uuid::Uuid::parse_str(id).is_ok(),
"Invalid Immich asset identity"
);
let name = item
.get("originalFileName")
.and_then(Value::as_str)
.context("Immich asset missing name")?;
let revision = asset_revision(item)?;
output.push(SourceItem {
source: self.identity.clone(),
upstream_id: id.into(),
locator: None,
name: name.into(),
preview_kind: asset_preview_kind(item),
mime_type: item
.get("originalMimeType")
.and_then(Value::as_str)
.map(str::to_owned),
size: item
.pointer("/exifInfo/fileSizeInByte")
.and_then(Value::as_u64),
revision,
directory: false,
preview: has("asset.view") && asset_preview_kind(item).is_some(),
download: has("asset.download"),
deletable: has("asset.delete")
&& item.get("ownerId").and_then(Value::as_str)
== Some(self.identity.account_id.as_str()),
});
}
let next_page = match assets.get("nextPage") {
Some(Value::Null) => None,
Some(Value::String(value)) => Some(
value
.parse::<u32>()
.context("Invalid Immich continuation")?,
),
_ => anyhow::bail!("Immich continuation unavailable"),
};
ensure!(
next_page.is_none_or(|next| next == page + 1 && next <= 100_000),
"Immich continuation did not advance safely"
);
Ok(SourcePage {
upload: has("asset.upload"),
listing_revision: None,
albums,
items: output,
next_page,
})
}
}
fn parse_albums(value: Value) -> Result<Vec<SourceAlbum>> {
let items = value
.as_array()
.context("Immich album inventory unavailable")?;
ensure!(
items.len() <= 1000,
"Immich album inventory exceeds supported limit"
);
let mut ids = std::collections::HashSet::new();
let mut albums = Vec::with_capacity(items.len());
for item in items {
let id = uuid::Uuid::parse_str(
item.get("id")
.and_then(Value::as_str)
.context("Immich album identity unavailable")?,
)?
.to_string();
ensure!(ids.insert(id.clone()), "Duplicate Immich album identity");
let name = item
.get("albumName")
.and_then(Value::as_str)
.context("Immich album name unavailable")?;
ensure!(
name.len() <= 4096 && !name.chars().any(char::is_control),
"Invalid Immich album name"
);
albums.push(SourceAlbum {
id,
name: name.to_owned(),
});
}
Ok(albums)
}
fn asset_preview_kind(item: &Value) -> Option<PreviewKind> {
match item.get("type").and_then(Value::as_str) {
Some("IMAGE") => Some(PreviewKind::Image),
Some("VIDEO") => Some(PreviewKind::Video),
_ => None,
}
}
fn asset_revision(item: &Value) -> Result<String> {
let checksum = item
.get("checksum")
.and_then(Value::as_str)
.context("Immich asset revision unavailable")?;
let updated = item
.get("updatedAt")
.and_then(Value::as_str)
.context("Immich asset revision unavailable")?;
Ok(hex::encode(Sha256::digest(serde_json::to_vec(&(
checksum,
updated,
item.get("type").and_then(Value::as_str),
))?)))
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::{
service::{make_service_fn, service_fn},
Body, Request, Response, Server,
};
use std::{
convert::Infallible,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
const ACCOUNT_A: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const ACCOUNT_B: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const ASSET: &str = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const ALBUM_A: &str = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
const ALBUM_B: &str = "ffffffff-ffff-4fff-8fff-ffffffffffff";
async fn fixture() -> (
InstalledTarget,
Arc<AtomicUsize>,
tokio::task::JoinHandle<()>,
) {
fixture_media(false, Arc::new(AtomicUsize::new(0))).await
}
// Modes: 0 normal, 1 revoked view permission, 2 changed metadata, 3 wrong media MIME.
async fn fixture_media(
video: bool,
mode: Arc<AtomicUsize>,
) -> (
InstalledTarget,
Arc<AtomicUsize>,
tokio::task::JoinHandle<()>,
) {
assert_eq!(std::env::var("ARCHY_TEST_ISOLATED").as_deref(), Ok("1"));
let searches = Arc::new(AtomicUsize::new(0));
let observed = searches.clone();
let socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
socket.set_nonblocking(true).unwrap();
let port = socket.local_addr().unwrap().port();
let service = make_service_fn(move |_| {
let searches = observed.clone();
let mode = mode.clone();
async move {
Ok::<_, Infallible>(service_fn(move |request: Request<Body>| {
let searches = searches.clone();
let mode = mode.clone();
async move {
let account = match request
.headers()
.get("x-api-key")
.and_then(|h| h.to_str().ok())
{
Some("synthetic-a") => ACCOUNT_A,
Some("synthetic-b") => ACCOUNT_B,
_ => {
return Ok::<_, Infallible>(
Response::builder().status(401).body(Body::empty()).unwrap(),
)
}
};
let media_path = if video {
format!("/api/assets/{ASSET}/video/playback")
} else {
format!("/api/assets/{ASSET}/thumbnail")
};
if request.uri().path() == media_path {
if video {
assert!(request.uri().query().is_none());
}
let bytes = b"abcdefgh";
let selected = request.headers().get("range").map(|_| (2usize, 5usize));
if request.method() != hyper::Method::HEAD {
assert_eq!(
request.headers()["if-unmodified-since"],
"Thu, 08 Oct 2026 00:00:00 GMT"
);
}
let payload = if request.method() == hyper::Method::HEAD {
&bytes[..0]
} else if let Some((start, end)) = selected {
&bytes[start..=end]
} else {
&bytes[..]
};
let mut response = Response::builder()
.status(if selected.is_some() { 206 } else { 200 })
.header("etag", "W/\"synthetic-one\"")
.header("last-modified", "Thu, 08 Oct 2026 00:00:00 GMT")
.header(
"content-type",
if mode.load(Ordering::SeqCst) == 3 {
"text/html"
} else if video {
"video/mp4"
} else {
"image/jpeg"
},
)
.header(
"content-length",
if selected.is_some() { 4u64 } else { 8u64 },
);
if selected.is_some() {
response = response.header("content-range", "bytes 2-5/8");
}
return Ok(response.body(Body::from(payload.to_vec())).unwrap());
}
let value = match request.uri().path() {
"/api/server/version" => json!({"major":2,"minor":7,"patch":4}),
"/api/users/me" => json!({"id":account}),
path if path == format!("/api/assets/{ASSET}") => {
json!({"id":ASSET,"originalFileName":"same-name.jpg","type":if video {"VIDEO"} else {"IMAGE"},"checksum":if mode.load(Ordering::SeqCst) == 2 {"changed"} else {"synthetic-revision"},"updatedAt":"2026-10-08T00:00:00Z","isTrashed":false})
}
"/api/api-keys/me" => {
json!({"permissions":match mode.load(Ordering::SeqCst) {
1 => vec!["user.read","asset.read"],
4 | 5 => vec!["user.read","asset.read","asset.view","album.read"],
_ => vec!["user.read","asset.read","asset.view"],
}})
}
"/api/albums" => {
if mode.load(Ordering::SeqCst) == 5 {
json!([])
} else {
json!([{"id":if account == ACCOUNT_A { ALBUM_A } else { ALBUM_B },"albumName":"Private album"}])
}
}
"/api/search/metadata" => {
searches.fetch_add(1, Ordering::SeqCst);
let body: Value = serde_json::from_slice(
&hyper::body::to_bytes(request.into_body()).await.unwrap(),
)
.unwrap();
assert_eq!(body["size"], 100);
assert_eq!(body["withDeleted"], false);
if let Some(albums) = body.get("albumIds") {
assert_eq!(
*albums,
json!([if account == ACCOUNT_A {
ALBUM_A
} else {
ALBUM_B
}])
);
}
json!({"assets":{"items":[{"id":ASSET,"ownerId":account,"originalFileName":"same-name.jpg","originalPath":"/private/source/path-never-exposed","originalMimeType":if video {"video/quicktime"} else {"image/jpeg"},"type":if video {"VIDEO"} else {"IMAGE"},"checksum":"synthetic-revision","updatedAt":"2026-10-08T00:00:00Z","exifInfo":{"fileSizeInByte":128},"isTrashed":false}],"nextPage":null}})
}
_ => panic!("Unexpected synthetic source route"),
};
Ok(Response::new(Body::from(
serde_json::to_vec(&value).unwrap(),
)))
}
}))
}
});
let task = tokio::spawn(async move {
Server::from_tcp(socket)
.unwrap()
.serve(service)
.await
.unwrap()
});
(
InstalledTarget::fixture(SourceKind::Immich, port),
searches,
task,
)
}
fn identity(account: &str) -> SourceIdentity {
SourceIdentity {
connection_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd".into(),
kind: SourceKind::Immich,
installation: "synthetic-installation".into(),
account_id: account.into(),
}
}
#[test]
fn album_inventory_rejects_invalid_duplicate_or_oversized_metadata() {
assert!(parse_albums(json!([{"id":ALBUM_A,"albumName":"A"}])).is_ok());
assert!(parse_albums(json!([{"id":"../private","albumName":"A"}])).is_err());
assert!(parse_albums(
json!([{"id":ALBUM_A,"albumName":"A"},{"id":ALBUM_A.to_uppercase(),"albumName":"B"}])
)
.is_err());
assert!(parse_albums(json!([{"id":ALBUM_A,"albumName":"bad\nname"}])).is_err());
assert!(parse_albums(json!([{"id":ALBUM_A,"albumName":"x".repeat(4097)}])).is_err());
assert!(parse_albums(json!(vec![json!({"id":ALBUM_A,"albumName":"A"}); 1001])).is_err());
}
#[tokio::test]
async fn album_filter_is_account_permission_bound_and_rechecked_before_search() {
let mode = Arc::new(AtomicUsize::new(4));
let (target, count, task) = fixture_media(false, mode.clone()).await;
let a = ImmichAdapter::new(target.clone(), identity(ACCOUNT_A), "synthetic-a").unwrap();
let b = ImmichAdapter::new(target, identity(ACCOUNT_B), "synthetic-b").unwrap();
let page = a.list_album(1, Some(ALBUM_A)).await.unwrap();
assert_eq!(page.albums.unwrap()[0].id, ALBUM_A);
assert_eq!(page.items.len(), 1);
assert_eq!(count.load(Ordering::SeqCst), 1);
assert!(b.list_album(1, Some(ALBUM_A)).await.is_err());
assert!(a.list_album(1, Some("../private")).await.is_err());
mode.store(5, Ordering::SeqCst);
assert!(a.list_album(2, Some(ALBUM_A)).await.is_err());
mode.store(0, Ordering::SeqCst);
assert!(a.list_album(1, Some(ALBUM_A)).await.is_err());
assert_eq!(count.load(Ordering::SeqCst), 1);
assert!(
a.list(1).await.unwrap().albums.is_none(),
"Existing asset-only key still works"
);
task.abort();
}
#[tokio::test]
async fn separate_accounts_keep_distinct_item_scope_and_do_not_invent_download_permission() {
let (target, count, task) = fixture().await;
let a = ImmichAdapter::new(target.clone(), identity(ACCOUNT_A), "synthetic-a")
.unwrap()
.list(1)
.await
.unwrap();
let b = ImmichAdapter::new(target, identity(ACCOUNT_B), "synthetic-b")
.unwrap()
.list(1)
.await
.unwrap();
task.abort();
assert_eq!(count.load(Ordering::SeqCst), 2);
assert_eq!(a.items[0].name, b.items[0].name);
assert_ne!(a.items[0].source, b.items[0].source);
assert!(a.items[0].preview);
assert!(!a.items[0].download);
let rendered = serde_json::to_string(&a).unwrap();
assert!(!rendered.contains("private/source"));
assert!(!rendered.contains("synthetic-a"));
}
#[tokio::test]
async fn authorized_preview_stream_rechecks_metadata_and_forwards_exact_range_without_download_scope(
) {
let (target, _, task) = fixture().await;
let adapter = ImmichAdapter::new(target, identity(ACCOUNT_A), "synthetic-a").unwrap();
let revision = asset_revision(
&json!({"type":"IMAGE","checksum":"synthetic-revision","updatedAt":"2026-10-08T00:00:00Z"}),
)
.unwrap();
let mut request = super::super::handles::StreamRequest {
connection_id: identity(ACCOUNT_A).connection_id,
upstream_id: ASSET.into(),
revision,
locator: None,
download: false,
};
let mut headers = hyper::HeaderMap::new();
headers.insert("range", "bytes=2-5".parse().unwrap());
let (_, response) = adapter.open_stream(&request, &headers).await.unwrap();
assert_eq!(response.status(), 206);
assert_eq!(response.headers()["content-range"], "bytes 2-5/8");
assert_eq!(
&hyper::body::to_bytes(response.into_body()).await.unwrap()[..],
b"cdef"
);
request.download = true;
assert!(adapter.open_stream(&request, &headers).await.is_err());
task.abort();
}
#[tokio::test]
async fn read_only_key_cannot_upload_or_delete() {
let (target, _, task) = fixture().await;
let adapter = ImmichAdapter::connect(target, "synthetic-a").await.unwrap();
let page = adapter.list(1).await.unwrap();
assert!(!page.upload);
assert!(!page.items[0].deletable);
let item = &page.items[0];
let request = super::super::handles::StreamRequest {
connection_id: item.source.connection_id.clone(),
upstream_id: item.upstream_id.clone(),
revision: item.revision.clone(),
locator: None,
download: false,
};
assert!(adapter
.delete(&request)
.await
.unwrap_err()
.to_string()
.contains("cannot delete"));
let file = tokio::fs::File::from_std(tempfile::tempfile().unwrap());
assert!(adapter
.upload(&[], "test.png", file, 0)
.await
.unwrap_err()
.to_string()
.contains("cannot upload"));
task.abort();
}
#[tokio::test]
async fn setup_derives_verified_account_without_user_supplied_uuid() {
let (target, count, task) = fixture().await;
let adapter = ImmichAdapter::connect(target, "synthetic-b").await.unwrap();
assert_eq!(adapter.identity().account_id, ACCOUNT_B);
assert!(uuid::Uuid::parse_str(&adapter.identity().connection_id).is_ok());
assert_eq!(count.load(Ordering::SeqCst), 0);
task.abort();
}
#[tokio::test]
async fn wrong_account_and_changed_installation_fail_before_listing() {
let (target, count, task) = fixture().await;
let wrong = ImmichAdapter::new(target.clone(), identity(ACCOUNT_A), "synthetic-b").unwrap();
assert!(wrong.list(1).await.is_err());
let mut replaced = identity(ACCOUNT_A);
replaced.installation = "replacement".into();
assert!(ImmichAdapter::new(target, replaced, "synthetic-a").is_err());
task.abort();
assert_eq!(count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn video_preview_uses_playback_ranges_and_rechecks_permission_revision_and_mime() {
let mode = Arc::new(AtomicUsize::new(0));
let (target, _, task) = fixture_media(true, mode.clone()).await;
let adapter =
ImmichAdapter::new(target.clone(), identity(ACCOUNT_A), "synthetic-a").unwrap();
let page = adapter.list(1).await.unwrap();
assert_eq!(page.items[0].preview_kind, Some(PreviewKind::Video));
assert_eq!(page.items[0].mime_type.as_deref(), Some("video/quicktime"));
assert!(page.items[0].preview);
assert!(!page.items[0].download);
let mut request = super::super::handles::StreamRequest {
connection_id: identity(ACCOUNT_A).connection_id,
upstream_id: ASSET.into(),
revision: page.items[0].revision.clone(),
locator: None,
download: false,
};
let mut headers = hyper::HeaderMap::new();
headers.insert("range", "bytes=2-5".parse().unwrap());
let (_, response) = adapter.open_stream(&request, &headers).await.unwrap();
assert_eq!(response.status(), 206);
assert_eq!(response.headers()["content-type"], "video/mp4");
assert_eq!(response.headers()["content-range"], "bytes 2-5/8");
assert_eq!(
&hyper::body::to_bytes(response.into_body()).await.unwrap()[..],
b"cdef"
);
request.download = true;
assert!(adapter.open_stream(&request, &headers).await.is_err());
request.download = false;
for failure in [1, 2, 3] {
mode.store(failure, Ordering::SeqCst);
assert!(adapter.open_stream(&request, &headers).await.is_err());
}
mode.store(0, Ordering::SeqCst);
let wrong = ImmichAdapter::new(target, identity(ACCOUNT_B), "synthetic-a").unwrap();
assert!(wrong.open_stream(&request, &headers).await.is_err());
task.abort();
}
#[test]
fn preview_kind_uses_verified_asset_type_and_revision_pins_that_type() {
let image = json!({"type":"IMAGE","checksum":"same","updatedAt":"same"});
let video = json!({"type":"VIDEO","checksum":"same","updatedAt":"same"});
assert_ne!(
asset_revision(&image).unwrap(),
asset_revision(&video).unwrap()
);
assert_eq!(
asset_preview_kind(&json!({"type":"UNKNOWN","originalMimeType":"video/mp4"})),
None
);
}
}