mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
added android client
This commit is contained in:
1 parent
8b95cf7a05
commit
6db6909824
73 files changed
+9234
-106
No files matched your search
@@ -19,7 +19,6 @@ thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
directories.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
# The credential store, per platform. Without the target's feature `keyring`
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
|
||||
use crate::naming::{self, ParsedName};
|
||||
|
||||
/// Bump when adding a migration.
|
||||
pub const SCHEMA_VERSION: i64 = 5;
|
||||
pub const SCHEMA_VERSION: i64 = 7;
|
||||
|
||||
const MIGRATION_V1: &str = r#"
|
||||
CREATE TABLE nodes (
|
||||
@@ -128,6 +128,46 @@ const MIGRATION_V5: &str = r#"
|
||||
ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
|
||||
"#;
|
||||
|
||||
/// Content kept explicitly by the viewer. Unlike the block cache, these rows
|
||||
/// describe complete local files and therefore survive a disconnected launch.
|
||||
const MIGRATION_V6: &str = r#"
|
||||
CREATE TABLE offline_files (
|
||||
share_id TEXT NOT NULL, link_id TEXT NOT NULL, revision_id TEXT NOT NULL,
|
||||
block_sizes TEXT NOT NULL,
|
||||
PRIMARY KEY (share_id, link_id)
|
||||
);
|
||||
"#;
|
||||
|
||||
/// Language choices are a property of a show, not of an episode's muxing.
|
||||
const MIGRATION_V7: &str = r#"
|
||||
CREATE TABLE title_track_prefs (
|
||||
title_key TEXT PRIMARY KEY, audio_language TEXT, subtitle_language TEXT,
|
||||
subtitles INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfflineFile {
|
||||
pub revision_id: String,
|
||||
pub block_sizes: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TitleTrackPrefs {
|
||||
pub audio_language: Option<String>,
|
||||
pub subtitle_language: Option<String>,
|
||||
pub subtitles: bool,
|
||||
}
|
||||
impl Default for TitleTrackPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
audio_language: None,
|
||||
subtitle_language: None,
|
||||
subtitles: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||
const ABSOLUTE_SEASON: i64 = -1;
|
||||
|
||||
@@ -235,6 +275,18 @@ impl Catalog {
|
||||
tx.pragma_update(None, "user_version", 5)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if version < 6 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V6)?;
|
||||
tx.pragma_update(None, "user_version", 6)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if version < 7 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V7)?;
|
||||
tx.pragma_update(None, "user_version", 7)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -245,6 +297,25 @@ impl Catalog {
|
||||
/// intact rather than a half-replaced one. Watch state is keyed separately
|
||||
/// and deliberately untouched — a recrawl must not lose where you were.
|
||||
pub fn replace_share(&mut self, share_id: &str, nodes: &[CatalogNode]) -> Result<()> {
|
||||
self.replace_share_inner(share_id, nodes, false)
|
||||
}
|
||||
|
||||
/// Replace nodes while retaining stale offline path keys for explicit
|
||||
/// filesystem cleanup by a front end.
|
||||
pub fn replace_share_retaining_offline(
|
||||
&mut self,
|
||||
share_id: &str,
|
||||
nodes: &[CatalogNode],
|
||||
) -> Result<()> {
|
||||
self.replace_share_inner(share_id, nodes, true)
|
||||
}
|
||||
|
||||
fn replace_share_inner(
|
||||
&mut self,
|
||||
share_id: &str,
|
||||
nodes: &[CatalogNode],
|
||||
retain_offline: bool,
|
||||
) -> Result<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
|
||||
@@ -276,6 +347,10 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
if !retain_offline {
|
||||
tx.execute("DELETE FROM offline_files WHERE share_id = ?1 AND NOT EXISTS (SELECT 1 FROM nodes WHERE nodes.share_id = offline_files.share_id AND nodes.link_id = offline_files.link_id AND nodes.active_revision_id = offline_files.revision_id)", params![share_id])?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,16 +389,141 @@ impl Catalog {
|
||||
/// state alone — this is the viewer saying they are done with the share.
|
||||
/// Keeping positions for files they can no longer reach would only leave
|
||||
/// the "continue watching" row pointing at nothing.
|
||||
pub fn remove_share(&self, share_id: &str) -> Result<()> {
|
||||
self.conn
|
||||
.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
self.conn.execute(
|
||||
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM watch_state WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn offline_file(&self, share_id: &str, link_id: &str) -> Result<Option<OfflineFile>> {
|
||||
self.conn.query_row("SELECT revision_id, block_sizes FROM offline_files WHERE share_id=?1 AND link_id=?2", params![share_id, link_id], |row| {
|
||||
let sizes: String = row.get(1)?;
|
||||
Ok(OfflineFile { revision_id: row.get(0)?, block_sizes: serde_json::from_str(&sizes).map_err(|e| rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)))? })
|
||||
}).optional().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_offline_file(
|
||||
&self,
|
||||
share_id: &str,
|
||||
link_id: &str,
|
||||
file: &OfflineFile,
|
||||
) -> Result<()> {
|
||||
let sizes = serde_json::to_string(&file.block_sizes)
|
||||
.map_err(|e| crate::error::Error::Config(e.to_string()))?;
|
||||
self.conn.execute("INSERT INTO offline_files (share_id,link_id,revision_id,block_sizes) VALUES (?1,?2,?3,?4) ON CONFLICT(share_id,link_id) DO UPDATE SET revision_id=excluded.revision_id,block_sizes=excluded.block_sizes", params![share_id,link_id,file.revision_id,sizes])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a completed download and force its WAL transaction to stable
|
||||
/// storage before the caller discards its recovery journal.
|
||||
pub fn set_offline_file_durable(
|
||||
&self,
|
||||
share_id: &str,
|
||||
link_id: &str,
|
||||
file: &OfflineFile,
|
||||
) -> Result<()> {
|
||||
self.set_offline_file(share_id, link_id, file)?;
|
||||
self.conn
|
||||
.query_row("PRAGMA wal_checkpoint(FULL)", [], |_| Ok(()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every completed local revision, keyed like watch state.
|
||||
pub fn all_offline_files(&self) -> Result<HashMap<(String, String), OfflineFile>> {
|
||||
let mut statement = self
|
||||
.conn
|
||||
.prepare("SELECT share_id, link_id, revision_id, block_sizes FROM offline_files")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
let sizes: String = row.get(3)?;
|
||||
let block_sizes = serde_json::from_str(&sizes).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
3,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(error),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
(row.get(0)?, row.get(1)?),
|
||||
OfflineFile {
|
||||
revision_id: row.get(2)?,
|
||||
block_sizes,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||
}
|
||||
|
||||
/// Completed local revisions belonging to one share.
|
||||
///
|
||||
/// Callers that replace or remove a share need the paths before deleting
|
||||
/// the index rows. Keeping this query explicit prevents an SQL cascade
|
||||
/// from orphaning opaque media files on disk.
|
||||
pub fn offline_files_for_share(&self, share_id: &str) -> Result<HashMap<String, OfflineFile>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT link_id, revision_id, block_sizes FROM offline_files WHERE share_id=?1",
|
||||
)?;
|
||||
let rows = statement.query_map(params![share_id], |row| {
|
||||
let sizes: String = row.get(2)?;
|
||||
let block_sizes = serde_json::from_str(&sizes).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(error),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
OfflineFile {
|
||||
revision_id: row.get(1)?,
|
||||
block_sizes,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn remove_offline_file(&self, share_id: &str, link_id: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM offline_files WHERE share_id=?1 AND link_id=?2",
|
||||
params![share_id, link_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
|
||||
self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into)
|
||||
}
|
||||
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
|
||||
self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
TitleTrackPrefs {
|
||||
audio_language: row.get(1)?,
|
||||
subtitle_language: row.get(2)?,
|
||||
subtitles: row.get::<_, i64>(3)? != 0,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||
}
|
||||
|
||||
/// Every watch position there is, keyed by `(share_id, link_id)`.
|
||||
///
|
||||
/// What [`crate::library::Library::build`] joins against. A grid that asked
|
||||
@@ -1497,4 +1697,94 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_offline_record_keeps_the_online_catalog_and_other_downloads() {
|
||||
let catalog = Catalog::in_memory().expect("open");
|
||||
let first = OfflineFile {
|
||||
revision_id: "revision-1".to_owned(),
|
||||
block_sizes: vec![3, 5],
|
||||
};
|
||||
let second = OfflineFile {
|
||||
revision_id: "revision-2".to_owned(),
|
||||
block_sizes: vec![7],
|
||||
};
|
||||
catalog
|
||||
.set_offline_file("share", "first", &first)
|
||||
.expect("record first");
|
||||
catalog
|
||||
.set_offline_file("share", "second", &second)
|
||||
.expect("record second");
|
||||
|
||||
catalog
|
||||
.remove_offline_file("share", "first")
|
||||
.expect("remove first");
|
||||
|
||||
assert_eq!(
|
||||
catalog.offline_file("share", "first").expect("read first"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
catalog
|
||||
.offline_file("share", "second")
|
||||
.expect("read second"),
|
||||
Some(second)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recrawl_retains_stale_offline_index_until_bytes_can_be_removed() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let mut old = file("share", "episode", "Show.S01E01.mkv");
|
||||
old.active_revision_id = Some("old".to_owned());
|
||||
catalog.replace_share("share", &[old]).expect("first crawl");
|
||||
let offline = OfflineFile {
|
||||
revision_id: "old".to_owned(),
|
||||
block_sizes: vec![4, 7],
|
||||
};
|
||||
catalog
|
||||
.set_offline_file("share", "episode", &offline)
|
||||
.expect("record offline revision");
|
||||
|
||||
let mut new = file("share", "episode", "Show.S01E01.mkv");
|
||||
new.active_revision_id = Some("new".to_owned());
|
||||
catalog
|
||||
.replace_share_retaining_offline("share", &[new])
|
||||
.expect("recrawl");
|
||||
|
||||
assert_eq!(
|
||||
catalog
|
||||
.offline_file("share", "episode")
|
||||
.expect("read retained path key"),
|
||||
Some(offline),
|
||||
"filesystem owner must delete old bytes before dropping this path key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_recrawl_prunes_stale_offline_index_for_legacy_callers() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let mut old = file("share", "episode", "Show.S01E01.mkv");
|
||||
old.active_revision_id = Some("old".to_owned());
|
||||
catalog.replace_share("share", &[old]).expect("first crawl");
|
||||
catalog
|
||||
.set_offline_file(
|
||||
"share",
|
||||
"episode",
|
||||
&OfflineFile {
|
||||
revision_id: "old".to_owned(),
|
||||
block_sizes: vec![4],
|
||||
},
|
||||
)
|
||||
.expect("offline row");
|
||||
let mut new = file("share", "episode", "Show.S01E01.mkv");
|
||||
new.active_revision_id = Some("new".to_owned());
|
||||
catalog.replace_share("share", &[new]).expect("recrawl");
|
||||
assert_eq!(
|
||||
catalog
|
||||
.offline_file("share", "episode")
|
||||
.expect("read offline row"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,25 @@ pub struct AppDirs {
|
||||
}
|
||||
|
||||
impl AppDirs {
|
||||
/// Use directories supplied by a platform host.
|
||||
///
|
||||
/// Android owns its application directories and exposes them through
|
||||
/// `Context`; resolving them through desktop conventions would put state in
|
||||
/// a path that does not exist inside the application sandbox.
|
||||
pub fn from_paths(
|
||||
config: impl Into<PathBuf>,
|
||||
data: impl Into<PathBuf>,
|
||||
cache: impl Into<PathBuf>,
|
||||
) -> Result<Self> {
|
||||
let dirs = Self {
|
||||
config: config.into(),
|
||||
data: data.into(),
|
||||
cache: cache.into(),
|
||||
};
|
||||
dirs.create()?;
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
/// Resolve the platform directories and create them.
|
||||
pub fn ensure() -> Result<Self> {
|
||||
let dirs =
|
||||
@@ -43,10 +62,15 @@ impl AppDirs {
|
||||
cache: dirs.cache_dir().to_path_buf(),
|
||||
};
|
||||
|
||||
for path in [&dirs.config, &dirs.data, &dirs.cache] {
|
||||
dirs.create()?;
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
fn create(&self) -> Result<()> {
|
||||
for path in [&self.config, &self.data, &self.cache] {
|
||||
fs::create_dir_all(path)?;
|
||||
}
|
||||
Ok(dirs)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The share list.
|
||||
@@ -64,6 +88,26 @@ impl AppDirs {
|
||||
self.cache.join("blocks")
|
||||
}
|
||||
|
||||
/// Complete files explicitly kept for disconnected playback.
|
||||
pub fn offline_content(&self) -> PathBuf {
|
||||
self.data.join("offline")
|
||||
}
|
||||
|
||||
/// Opaque path for one complete offline revision. No media name or share
|
||||
/// secret reaches the filesystem.
|
||||
pub fn offline_file(&self, share_id: &str, link_id: &str, revision: &str) -> PathBuf {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(share_id.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(link_id.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(revision.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let name: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
self.offline_content().join(format!("{name}.media"))
|
||||
}
|
||||
|
||||
/// Where decrypted poster thumbnails are kept.
|
||||
///
|
||||
/// Separate from the block cache because it is cheap, tiny and wanted on
|
||||
|
||||
@@ -15,6 +15,7 @@ pub enum Error {
|
||||
///
|
||||
/// Boxed because `keyring::Error` is large and this variant is rare; an
|
||||
/// un-boxed one would inflate every `Result` in the crate.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[error("credential store: {0}")]
|
||||
Keyring(#[from] Box<keyring::Error>),
|
||||
|
||||
@@ -31,6 +32,7 @@ pub enum Error {
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl From<keyring::Error> for Error {
|
||||
fn from(error: keyring::Error) -> Self {
|
||||
Self::Keyring(Box::new(error))
|
||||
|
||||
@@ -16,7 +16,9 @@ pub mod prefs;
|
||||
pub mod shares;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use shares::{Share, ShareStore, SharedLibrary};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub use shares::KeyringSecretStore;
|
||||
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary};
|
||||
|
||||
pub use proton_drive_rs;
|
||||
pub use proton_sdk;
|
||||
@@ -16,6 +16,7 @@
|
||||
//! regardless of how many links it was given.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
@@ -26,8 +27,55 @@ use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The credential-store service name. One entry per share, keyed by share id.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const KEYRING_SERVICE: &str = "proton-stream";
|
||||
|
||||
/// Secret persistence supplied by the platform.
|
||||
///
|
||||
/// Desktop builds use [`KeyringSecretStore`]. Android supplies an
|
||||
/// implementation backed by a non-exportable Android Keystore key through the
|
||||
/// language bridge, keeping platform APIs out of this portable crate.
|
||||
pub trait SecretStore: Send + Sync {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()>;
|
||||
fn get(&self, key: &str) -> Result<Option<String>>;
|
||||
fn delete(&self, key: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// The desktop OS credential store.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KeyringSecretStore;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl KeyringSecretStore {
|
||||
fn entry(key: &str) -> Result<keyring::Entry> {
|
||||
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl SecretStore for KeyringSecretStore {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
Self::entry(key)?.set_password(value)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
match Self::entry(key)?.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<()> {
|
||||
match Self::entry(key)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app records about a share, minus its secrets.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Share {
|
||||
@@ -59,11 +107,17 @@ struct ShareSecrets {
|
||||
/// The configured shares, and their secrets.
|
||||
pub struct ShareStore {
|
||||
dirs: AppDirs,
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
}
|
||||
|
||||
impl ShareStore {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn new(dirs: AppDirs) -> Self {
|
||||
Self { dirs }
|
||||
Self::with_secret_store(dirs, Arc::new(KeyringSecretStore))
|
||||
}
|
||||
|
||||
pub fn with_secret_store(dirs: AppDirs, secrets: Arc<dyn SecretStore>) -> Self {
|
||||
Self { dirs, secrets }
|
||||
}
|
||||
|
||||
/// Every configured share, in the order they were added.
|
||||
@@ -121,10 +175,7 @@ impl ShareStore {
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
|
||||
// A missing entry is the desired end state, not a failure.
|
||||
match self.entry(id)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
self.secrets.delete(id)
|
||||
}
|
||||
|
||||
/// Open a share as a visitor.
|
||||
@@ -143,27 +194,18 @@ impl ShareStore {
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn entry(&self, id: &str) -> Result<keyring::Entry> {
|
||||
Ok(keyring::Entry::new(KEYRING_SERVICE, id)?)
|
||||
}
|
||||
|
||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||
let encoded = serde_json::to_string(secrets)
|
||||
.map_err(|e| Error::Config(format!("serialize share secrets: {e}")))?;
|
||||
self.entry(id)?.set_password(&encoded)?;
|
||||
Ok(())
|
||||
self.secrets.set(id, &encoded)
|
||||
}
|
||||
|
||||
fn load_secrets(&self, id: &str) -> Result<ShareSecrets> {
|
||||
let encoded = match self.entry(id)?.get_password() {
|
||||
Ok(encoded) => encoded,
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
return Err(Error::NotFound(format!(
|
||||
"share {id} has no stored credentials; remove and re-add it"
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let encoded = self.secrets.get(id)?.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"share {id} has no stored credentials; remove and re-add it"
|
||||
))
|
||||
})?;
|
||||
serde_json::from_str(&encoded)
|
||||
.map_err(|e| Error::Config(format!("stored share secrets are unreadable: {e}")))
|
||||
}
|
||||
@@ -206,6 +248,17 @@ impl SharedLibrary {
|
||||
self.clients.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Replay a public-link handshake before performing a user-requested
|
||||
/// refresh. Visitor sessions are short-lived, so a cached client is still
|
||||
/// useful for streaming but must not be assumed valid for a later crawl.
|
||||
pub async fn refresh_session(&self, share_id: &str) -> Result<()> {
|
||||
let client = self
|
||||
.client(share_id)
|
||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||
client.refresh_session().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk one share's whole subtree, depth first, yielding every node.
|
||||
///
|
||||
/// There is no recursion helper on the visitor path — the authenticated
|
||||
@@ -266,7 +319,7 @@ fn client_configuration() -> ProtonClientConfiguration {
|
||||
fn token_from_url(url: &str) -> Result<String> {
|
||||
let (_, tail) = url
|
||||
.split_once("/urls/")
|
||||
.ok_or_else(|| Error::Config(format!("{url} is not a Proton share URL")))?;
|
||||
.ok_or_else(|| Error::Config("not a Proton share URL".to_owned()))?;
|
||||
|
||||
let token = tail
|
||||
.split(['#', '?'])
|
||||
@@ -275,12 +328,14 @@ fn token_from_url(url: &str) -> Result<String> {
|
||||
.trim_end_matches('/');
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(Error::Config(format!("{url} carries no share token")));
|
||||
return Err(Error::Config(
|
||||
"Proton share URL carries no share token".to_owned(),
|
||||
));
|
||||
}
|
||||
if !url.contains('#') {
|
||||
return Err(Error::Config(format!(
|
||||
"{url} has no #password fragment; copy the full share link"
|
||||
)));
|
||||
return Err(Error::Config(
|
||||
"Proton share URL has no #password fragment; copy the full share link".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(token.to_string())
|
||||
}
|
||||
@@ -322,6 +377,25 @@ mod tests {
|
||||
assert!(token_from_url("https://drive.proton.me/urls/#s3cr3t").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_share_errors_never_repeat_the_secret_url_fragment() {
|
||||
const SECRET: &str = "fragment-secret-that-must-not-leak";
|
||||
for url in [
|
||||
format!("https://example.com/not-a-share#{SECRET}"),
|
||||
format!("https://drive.proton.me/urls/#{SECRET}"),
|
||||
] {
|
||||
let error = token_from_url(&url).unwrap_err().to_string();
|
||||
assert!(
|
||||
!error.contains(SECRET),
|
||||
"error exposed URL fragment: {error}"
|
||||
);
|
||||
assert!(
|
||||
!error.contains(&url),
|
||||
"error repeated the full URL: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The id must be derivable from the token alone — deriving it from the URL
|
||||
/// would mix a secret into the config file.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user