mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
init, scaffhold + fuse
This commit is contained in:
commit
e9c8ddd18b
12 files changed
+5580
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pdb
|
||||||
|
.env
|
||||||
Generated
+4635
File diff suppressed because it is too large.
Load diff
+33
@@ -0,0 +1,33 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
rust-version = "1.96"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Local Proton SDK crates (path deps until published; switch to versioned later).
|
||||||
|
proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" }
|
||||||
|
proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" }
|
||||||
|
|
||||||
|
# Internal crates.
|
||||||
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
pdfs-fuse = { path = "crates/pdfs-fuse" }
|
||||||
|
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
directories = "5"
|
||||||
|
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
rpassword = "7"
|
||||||
|
fuser = "0.17"
|
||||||
|
libc = "0.2"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "pdfs-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "CLI for the Proton Drive Linux client"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pdfs"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pdfs-core.workspace = true
|
||||||
|
pdfs-fuse.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
rpassword.workspace = true
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//! `pdfs` — command-line front-end for the Proton Drive Linux client.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use pdfs_core::auth;
|
||||||
|
use pdfs_core::config::AppDirs;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "pdfs", version, about = "Proton Drive for Linux (Files On-Demand)")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Log in to a Proton account (SRP + optional 2FA) and store the session.
|
||||||
|
Login {
|
||||||
|
/// Account email; prompted if omitted.
|
||||||
|
#[arg(short, long)]
|
||||||
|
username: Option<String>,
|
||||||
|
},
|
||||||
|
/// Forget the stored session.
|
||||||
|
Logout,
|
||||||
|
/// Show the currently logged-in account.
|
||||||
|
Status,
|
||||||
|
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
|
||||||
|
Mount {
|
||||||
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
|
mountpoint: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
match cli.command {
|
||||||
|
Command::Login { username } => cmd_login(username),
|
||||||
|
Command::Logout => cmd_logout(),
|
||||||
|
Command::Status => cmd_status(),
|
||||||
|
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a line from stdin with a prompt (echoed).
|
||||||
|
fn prompt_line(label: &str) -> Result<String> {
|
||||||
|
print!("{label}: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
Ok(line.trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_login(username: Option<String>) -> Result<()> {
|
||||||
|
let username = match username {
|
||||||
|
Some(u) => u,
|
||||||
|
None => prompt_line("Proton email")?,
|
||||||
|
};
|
||||||
|
let password = rpassword::prompt_password("Password: ").context("read password")?;
|
||||||
|
|
||||||
|
// The 2FA prompt fires lazily — only if the account actually requires it.
|
||||||
|
let get_totp = || -> pdfs_core::Result<String> {
|
||||||
|
prompt_line("2FA code")
|
||||||
|
.map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
||||||
|
};
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
rt.block_on(auth::login(&username, &password, get_totp))
|
||||||
|
.context("login failed")?;
|
||||||
|
|
||||||
|
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_logout() -> Result<()> {
|
||||||
|
auth::logout()?;
|
||||||
|
println!("Stored session cleared.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_status() -> Result<()> {
|
||||||
|
match auth::load() {
|
||||||
|
Ok(s) => {
|
||||||
|
println!("Logged in as {} (user {})", s.username, s.user_id);
|
||||||
|
println!("Scopes: {}", s.scopes.join(", "));
|
||||||
|
}
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn) => println!("Not logged in. Run `pdfs login`."),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
|
let dirs = AppDirs::new()?;
|
||||||
|
dirs.ensure()?;
|
||||||
|
let mountpoint = mountpoint.unwrap_or_else(|| dirs.default_mountpoint());
|
||||||
|
std::fs::create_dir_all(&mountpoint)
|
||||||
|
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
|
||||||
|
|
||||||
|
// Multi-threaded runtime: its worker threads keep servicing async SDK calls
|
||||||
|
// while the main thread is parked inside the blocking FUSE session loop.
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
let (client, _session) = rt
|
||||||
|
.block_on(auth::resume_client())
|
||||||
|
.context("resume session (run `pdfs login` first)")?;
|
||||||
|
|
||||||
|
let handle = rt.handle().clone();
|
||||||
|
pdfs_fuse::mount(client, handle, &mountpoint).context("mount failed")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "pdfs-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "Core auth/session/config for the Proton Drive Linux client"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proton-sdk.workspace = true
|
||||||
|
proton-drive-rs.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
directories.workspace = true
|
||||||
|
keyring.workspace = true
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! Login, session persistence, and Drive-client construction.
|
||||||
|
//!
|
||||||
|
//! A successful login is persisted to the OS keyring as a single JSON blob
|
||||||
|
//! holding the resumable session tokens plus the mailbox password (needed to
|
||||||
|
//! rebuild the key chain on resume). The daemon resumes from this blob with no
|
||||||
|
//! interactive step; the refresh token auto-renews via the HTTP client's 401
|
||||||
|
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
||||||
|
|
||||||
|
use keyring::Entry;
|
||||||
|
use proton_drive_rs::ProtonDriveClient;
|
||||||
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::{APP_VERSION, KEYRING_SERVICE, USER_AGENT};
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// Fixed keyring account name for the single stored session blob.
|
||||||
|
const KEYRING_USER: &str = "session";
|
||||||
|
|
||||||
|
/// Everything needed to resume a session unattended, persisted to the keyring.
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct StoredSession {
|
||||||
|
pub session_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
/// `1` = single password, `2` = dual (Proton wire value).
|
||||||
|
pub password_mode: u8,
|
||||||
|
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
|
||||||
|
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
|
||||||
|
pub mailbox_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredSession {
|
||||||
|
fn into_params(&self) -> ResumeParameters {
|
||||||
|
ResumeParameters {
|
||||||
|
session_id: self.session_id.clone().into(),
|
||||||
|
username: self.username.clone(),
|
||||||
|
user_id: self.user_id.clone().into(),
|
||||||
|
access_token: self.access_token.clone(),
|
||||||
|
refresh_token: self.refresh_token.clone(),
|
||||||
|
scopes: self.scopes.clone(),
|
||||||
|
is_waiting_for_second_factor_code: false,
|
||||||
|
password_mode: match self.password_mode {
|
||||||
|
1 => PasswordMode::Single,
|
||||||
|
_ => PasswordMode::Dual,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_config() -> ProtonClientConfiguration {
|
||||||
|
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyring_entry() -> Result<Entry> {
|
||||||
|
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run an interactive SRP + (optional) 2FA login and persist the session.
|
||||||
|
///
|
||||||
|
/// `get_totp` is only invoked when the account requires a second factor, so
|
||||||
|
/// callers can defer prompting until it is actually needed.
|
||||||
|
pub async fn login(
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
get_totp: impl FnOnce() -> Result<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut session =
|
||||||
|
ProtonApiSession::begin(client_config(), username, password.as_bytes()).await?;
|
||||||
|
|
||||||
|
if session.is_waiting_for_second_factor() {
|
||||||
|
let code = get_totp()?;
|
||||||
|
session.apply_second_factor_code(code.trim()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(&session, password).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the session's current tokens + mailbox password to the keyring.
|
||||||
|
pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<()> {
|
||||||
|
let tokens = session.current_tokens().await;
|
||||||
|
let stored = StoredSession {
|
||||||
|
session_id: session.session_id().as_str().to_owned(),
|
||||||
|
username: session.username().to_owned(),
|
||||||
|
user_id: session.user_id().as_str().to_owned(),
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
scopes: session.scopes().to_vec(),
|
||||||
|
password_mode: match session.password_mode() {
|
||||||
|
PasswordMode::Single => 1,
|
||||||
|
PasswordMode::Dual => 2,
|
||||||
|
},
|
||||||
|
mailbox_password: mailbox_password.to_owned(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&stored)?;
|
||||||
|
keyring_entry()?.set_password(&json)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the persisted session blob, or `Error::NotLoggedIn` if absent.
|
||||||
|
pub fn load() -> Result<StoredSession> {
|
||||||
|
let entry = keyring_entry()?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(json) => Ok(serde_json::from_str(&json)?),
|
||||||
|
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the persisted session (best-effort; absent entry is not an error).
|
||||||
|
pub fn logout() -> Result<()> {
|
||||||
|
match keyring_entry()?.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume a persisted session and build an authenticated Drive client.
|
||||||
|
///
|
||||||
|
/// Re-persists tokens afterward in case the refresh path rotated them. Returns
|
||||||
|
/// `Error::NotLoggedIn` when no session has been saved.
|
||||||
|
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||||
|
let stored = load()?;
|
||||||
|
let session = ProtonApiSession::resume(client_config(), stored.into_params())?;
|
||||||
|
let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes());
|
||||||
|
Ok((client, session))
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//! Static client identity and per-user filesystem paths.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||||
|
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||||
|
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||||
|
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||||
|
|
||||||
|
/// Keyring service name; one entry per credential kind keyed by username.
|
||||||
|
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||||
|
|
||||||
|
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
||||||
|
pub struct AppDirs {
|
||||||
|
dirs: ProjectDirs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppDirs {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let dirs = ProjectDirs::from("io", "narl", "proton-drive-linux")
|
||||||
|
.ok_or_else(|| Error::Other("cannot resolve home directory".into()))?;
|
||||||
|
Ok(Self { dirs })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent state: inode map / cache index DB lives here.
|
||||||
|
pub fn state_dir(&self) -> PathBuf {
|
||||||
|
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
||||||
|
self.dirs
|
||||||
|
.state_dir()
|
||||||
|
.unwrap_or_else(|| self.dirs.data_dir())
|
||||||
|
.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hydrated file-content cache.
|
||||||
|
pub fn cache_dir(&self) -> PathBuf {
|
||||||
|
self.dirs.cache_dir().to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default mountpoint when the user does not pass one explicitly.
|
||||||
|
pub fn default_mountpoint(&self) -> PathBuf {
|
||||||
|
directories::UserDirs::new()
|
||||||
|
.map(|u| u.home_dir().join("ProtonDrive"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/tmp/ProtonDrive"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create state + cache dirs if missing.
|
||||||
|
pub fn ensure(&self) -> Result<()> {
|
||||||
|
std::fs::create_dir_all(self.state_dir())?;
|
||||||
|
std::fs::create_dir_all(self.cache_dir())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Crate-wide error type.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("proton api: {0}")]
|
||||||
|
Proton(#[from] proton_sdk::ProtonError),
|
||||||
|
|
||||||
|
#[error("keyring: {0}")]
|
||||||
|
Keyring(#[from] keyring::Error),
|
||||||
|
|
||||||
|
#[error("serialization: {0}")]
|
||||||
|
Serde(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// No persisted session found — caller must run an interactive login.
|
||||||
|
#[error("not logged in (no saved session)")]
|
||||||
|
NotLoggedIn,
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! Core building blocks for the Proton Drive Linux client: static client
|
||||||
|
//! identity, XDG paths, and the login/session/keyring layer that every
|
||||||
|
//! front-end (CLI today, daemon + GUI later) shares.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
// Re-export the SDK surface downstream crates need, so they depend on
|
||||||
|
// `pdfs-core` rather than pinning the SDK directly.
|
||||||
|
pub use proton_drive_rs::{self, Node, NodeKind, ProtonDriveClient};
|
||||||
|
pub use proton_sdk;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "pdfs-fuse"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
description = "FUSE on-demand filesystem for Proton Drive"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pdfs-core.workspace = true
|
||||||
|
proton-drive-rs.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
fuser.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
//! On-demand FUSE filesystem for Proton Drive.
|
||||||
|
//!
|
||||||
|
//! Phase 1 is a read-only placeholder mount: directories are enumerated lazily
|
||||||
|
//! from the remote on first access and file content is hydrated on `read` via
|
||||||
|
//! [`ProtonDriveClient::download_range`]. Nothing is mirrored to local disk;
|
||||||
|
//! every byte is fetched on demand.
|
||||||
|
//!
|
||||||
|
//! Phase 2 adds live remote sync: a background task polls the volume event
|
||||||
|
//! cursor ([`ProtonDriveClient::enumerate_events`]) and pushes invalidations
|
||||||
|
//! into the kernel via a fuser [`Notifier`], so the cache TTL can be long while
|
||||||
|
//! remote changes still show up promptly.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use fuser::{
|
||||||
|
Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation, INodeNo,
|
||||||
|
LockOwner, MountOption, Notifier, OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry,
|
||||||
|
ReplyOpen, Request, Session,
|
||||||
|
};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||||
|
use proton_drive_rs::{DriveEvent, DriveEventScopeId, Node, NodeKind, ProtonDriveClient};
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
/// Attribute/entry cache lifetime handed back to the kernel. Long because the
|
||||||
|
/// Phase 2 event poller actively invalidates changed inodes; without a remote
|
||||||
|
/// change this is how long the kernel may serve stale metadata.
|
||||||
|
const TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// How often the background task polls the remote event cursor.
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// The FUSE root inode is always 1.
|
||||||
|
const ROOT_INO: u64 = 1;
|
||||||
|
|
||||||
|
/// A node known to the filesystem, addressed by its kernel inode.
|
||||||
|
struct Entry {
|
||||||
|
uid: NodeUid,
|
||||||
|
parent: u64,
|
||||||
|
node: Node,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
||||||
|
/// `Filesystem` trait through `&self`.
|
||||||
|
struct State {
|
||||||
|
/// inode -> node metadata.
|
||||||
|
entries: HashMap<u64, Entry>,
|
||||||
|
/// Dedupe inodes by node uid so a node keeps a stable inode across lookups.
|
||||||
|
by_uid: HashMap<NodeUid, u64>,
|
||||||
|
/// Cached directory listings: parent inode -> child inodes. Presence of a
|
||||||
|
/// key means the directory has been enumerated.
|
||||||
|
children: HashMap<u64, Vec<u64>>,
|
||||||
|
next_ino: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
/// Allocate (or reuse) a stable inode for a node and store its metadata.
|
||||||
|
fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
||||||
|
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
||||||
|
if let Some(e) = self.entries.get_mut(&ino) {
|
||||||
|
e.node = node;
|
||||||
|
e.parent = parent;
|
||||||
|
}
|
||||||
|
return ino;
|
||||||
|
}
|
||||||
|
let ino = self.next_ino;
|
||||||
|
self.next_ino += 1;
|
||||||
|
self.by_uid.insert(node.uid.clone(), ino);
|
||||||
|
self.entries
|
||||||
|
.insert(ino, Entry { uid: node.uid.clone(), parent, node });
|
||||||
|
ino
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
|
||||||
|
/// listing, and its slot in its parent's listing. Returns `(parent_ino,
|
||||||
|
/// name)` when the node was known, so the caller can notify the kernel.
|
||||||
|
fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
||||||
|
let ino = self.by_uid.remove(uid)?;
|
||||||
|
let entry = self.entries.remove(&ino)?;
|
||||||
|
self.children.remove(&ino);
|
||||||
|
if let Some(kids) = self.children.get_mut(&entry.parent) {
|
||||||
|
kids.retain(|&k| k != ino);
|
||||||
|
}
|
||||||
|
Some((entry.parent, entry.node.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Proton Drive VFS. FUSE callbacks are synchronous, so the Tokio handle
|
||||||
|
/// bridges each one to the async SDK via [`Handle::block_on`]; the fuser
|
||||||
|
/// session thread is not a runtime worker, so blocking on it is sound.
|
||||||
|
pub struct ProtonFs {
|
||||||
|
client: ProtonDriveClient,
|
||||||
|
rt: tokio::runtime::Handle,
|
||||||
|
state: Arc<Mutex<State>>,
|
||||||
|
uid: u32,
|
||||||
|
gid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtonFs {
|
||||||
|
/// Build the filesystem rooted at `root` (the user's My Files folder).
|
||||||
|
pub fn new(client: ProtonDriveClient, rt: tokio::runtime::Handle, root: Node) -> Self {
|
||||||
|
let mut entries = HashMap::new();
|
||||||
|
let mut by_uid = HashMap::new();
|
||||||
|
by_uid.insert(root.uid.clone(), ROOT_INO);
|
||||||
|
entries.insert(
|
||||||
|
ROOT_INO,
|
||||||
|
Entry { uid: root.uid.clone(), parent: ROOT_INO, node: root },
|
||||||
|
);
|
||||||
|
// SAFETY: geteuid/getegid are infallible and have no preconditions.
|
||||||
|
let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) };
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
rt,
|
||||||
|
state: Arc::new(Mutex::new(State {
|
||||||
|
entries,
|
||||||
|
by_uid,
|
||||||
|
children: HashMap::new(),
|
||||||
|
next_ino: 2,
|
||||||
|
})),
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle to the shared inode state, for the event poller.
|
||||||
|
fn shared_state(&self) -> Arc<Mutex<State>> {
|
||||||
|
Arc::clone(&self.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate `ino`'s children from the remote and cache them. No-op if the
|
||||||
|
/// directory has already been listed. Network I/O happens without the lock
|
||||||
|
/// held so concurrent metadata reads aren't blocked behind a fetch.
|
||||||
|
fn ensure_children(&self, ino: u64) -> Result<(), Errno> {
|
||||||
|
let folder_uid = {
|
||||||
|
let st = self.state.lock().unwrap();
|
||||||
|
if st.children.contains_key(&ino) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match st.entries.get(&ino) {
|
||||||
|
Some(e) => e.uid.clone(),
|
||||||
|
None => return Err(Errno::ENOENT),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let uids = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.enumerate_folder_children_node_uids(&folder_uid))
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(%folder_uid, error = %e, "enumerate folder children failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
let nodes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.enumerate_nodes(&uids))
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(%folder_uid, error = %e, "enumerate nodes failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut st = self.state.lock().unwrap();
|
||||||
|
// Lost the race? Another thread already populated it.
|
||||||
|
if st.children.contains_key(&ino) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut child_inos = Vec::with_capacity(nodes.len());
|
||||||
|
for node in nodes {
|
||||||
|
if node.trashed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
child_inos.push(st.intern(ino, node));
|
||||||
|
}
|
||||||
|
st.children.insert(ino, child_inos);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attr(&self, ino: u64, node: &Node) -> FileAttr {
|
||||||
|
let (kind, perm) = match node.kind {
|
||||||
|
NodeKind::Folder => (FileType::Directory, 0o555),
|
||||||
|
NodeKind::File { .. } => (FileType::RegularFile, 0o444),
|
||||||
|
};
|
||||||
|
let size = node_size(node);
|
||||||
|
let mtime = unix_secs(node.modification_time);
|
||||||
|
let crtime = unix_secs(node.creation_time);
|
||||||
|
FileAttr {
|
||||||
|
ino: INodeNo(ino),
|
||||||
|
size,
|
||||||
|
blocks: size.div_ceil(512),
|
||||||
|
atime: mtime,
|
||||||
|
mtime,
|
||||||
|
ctime: mtime,
|
||||||
|
crtime,
|
||||||
|
kind,
|
||||||
|
perm,
|
||||||
|
nlink: if kind == FileType::Directory { 2 } else { 1 },
|
||||||
|
uid: self.uid,
|
||||||
|
gid: self.gid,
|
||||||
|
rdev: 0,
|
||||||
|
blksize: 512,
|
||||||
|
flags: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plaintext size, in bytes, that a node reports.
|
||||||
|
fn node_size(node: &Node) -> u64 {
|
||||||
|
match &node.kind {
|
||||||
|
NodeKind::Folder => 0,
|
||||||
|
NodeKind::File { claimed_size, total_size_on_storage, .. } => {
|
||||||
|
claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a Unix timestamp (seconds, possibly negative) to a `SystemTime`.
|
||||||
|
fn unix_secs(secs: i64) -> SystemTime {
|
||||||
|
if secs >= 0 {
|
||||||
|
UNIX_EPOCH + Duration::from_secs(secs as u64)
|
||||||
|
} else {
|
||||||
|
UNIX_EPOCH - Duration::from_secs(secs.unsigned_abs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Filesystem for ProtonFs {
|
||||||
|
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
|
||||||
|
let parent = parent.0;
|
||||||
|
if let Err(e) = self.ensure_children(parent) {
|
||||||
|
reply.error(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
let st = self.state.lock().unwrap();
|
||||||
|
let hit = st.children.get(&parent).and_then(|kids| {
|
||||||
|
kids.iter().copied().find_map(|ino| {
|
||||||
|
st.entries
|
||||||
|
.get(&ino)
|
||||||
|
.filter(|e| e.node.name == name)
|
||||||
|
.map(|e| (ino, &e.node))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match hit {
|
||||||
|
Some((ino, node)) => {
|
||||||
|
let attr = self.attr(ino, node);
|
||||||
|
reply.entry(&TTL, &attr, Generation(0));
|
||||||
|
}
|
||||||
|
None => reply.error(Errno::ENOENT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) {
|
||||||
|
let st = self.state.lock().unwrap();
|
||||||
|
match st.entries.get(&ino.0) {
|
||||||
|
Some(e) => {
|
||||||
|
let attr = self.attr(ino.0, &e.node);
|
||||||
|
reply.attr(&TTL, &attr);
|
||||||
|
}
|
||||||
|
None => reply.error(Errno::ENOENT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readdir(
|
||||||
|
&self,
|
||||||
|
_req: &Request,
|
||||||
|
ino: INodeNo,
|
||||||
|
_fh: FileHandle,
|
||||||
|
offset: u64,
|
||||||
|
mut reply: ReplyDirectory,
|
||||||
|
) {
|
||||||
|
let ino = ino.0;
|
||||||
|
if let Err(e) = self.ensure_children(ino) {
|
||||||
|
reply.error(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let st = self.state.lock().unwrap();
|
||||||
|
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
||||||
|
|
||||||
|
// "." and ".." occupy offsets 0 and 1; real children follow.
|
||||||
|
let mut listing: Vec<(u64, FileType, String)> = vec![
|
||||||
|
(ino, FileType::Directory, ".".to_string()),
|
||||||
|
(parent, FileType::Directory, "..".to_string()),
|
||||||
|
];
|
||||||
|
if let Some(kids) = st.children.get(&ino) {
|
||||||
|
for &kid in kids {
|
||||||
|
if let Some(e) = st.entries.get(&kid) {
|
||||||
|
let ft = if e.node.is_folder() {
|
||||||
|
FileType::Directory
|
||||||
|
} else {
|
||||||
|
FileType::RegularFile
|
||||||
|
};
|
||||||
|
listing.push((kid, ft, e.node.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(st);
|
||||||
|
|
||||||
|
for (i, (ino, ft, name)) in listing.into_iter().enumerate().skip(offset as usize) {
|
||||||
|
// The stored offset is that of the *next* entry to resume at.
|
||||||
|
if reply.add(INodeNo(ino), (i + 1) as u64, ft, &name) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&self, _req: &Request, ino: INodeNo, _flags: OpenFlags, reply: ReplyOpen) {
|
||||||
|
let st = self.state.lock().unwrap();
|
||||||
|
match st.entries.get(&ino.0) {
|
||||||
|
Some(e) if e.node.is_file() => reply.opened(FileHandle(0), FopenFlags::empty()),
|
||||||
|
Some(_) => reply.error(Errno::EISDIR),
|
||||||
|
None => reply.error(Errno::ENOENT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(
|
||||||
|
&self,
|
||||||
|
_req: &Request,
|
||||||
|
ino: INodeNo,
|
||||||
|
_fh: FileHandle,
|
||||||
|
offset: u64,
|
||||||
|
size: u32,
|
||||||
|
_flags: OpenFlags,
|
||||||
|
_lock_owner: Option<LockOwner>,
|
||||||
|
reply: ReplyData,
|
||||||
|
) {
|
||||||
|
let uid = {
|
||||||
|
let st = self.state.lock().unwrap();
|
||||||
|
match st.entries.get(&ino.0) {
|
||||||
|
Some(e) if e.node.is_file() => e.uid.clone(),
|
||||||
|
Some(_) => {
|
||||||
|
reply.error(Errno::EISDIR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
reply.error(Errno::ENOENT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.download_range(&uid, offset, size as u64))
|
||||||
|
{
|
||||||
|
Ok(bytes) => reply.data(&bytes),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, offset, size, error = %e, "download_range failed");
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one remote event to the local cache and notify the kernel so it drops
|
||||||
|
/// any stale cached metadata/data for the affected inodes.
|
||||||
|
///
|
||||||
|
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
||||||
|
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
||||||
|
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
||||||
|
fn apply_event(state: &Mutex<State>, notifier: &Notifier, event: &DriveEvent) {
|
||||||
|
match event {
|
||||||
|
DriveEvent::NodeUpdated { node_uid, parent_node_uid, is_trashed, .. } => {
|
||||||
|
let mut st = state.lock().unwrap();
|
||||||
|
if *is_trashed {
|
||||||
|
// Trashing makes a node vanish from its parent listing.
|
||||||
|
let child = st.by_uid.get(node_uid).copied();
|
||||||
|
if let Some((parent, name)) = st.forget(node_uid) {
|
||||||
|
match child {
|
||||||
|
Some(child) => {
|
||||||
|
let _ =
|
||||||
|
notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(&ino) = st.by_uid.get(node_uid) {
|
||||||
|
// Known node changed: drop its cached attrs/data (and listing if
|
||||||
|
// it is a directory) so the next access re-fetches.
|
||||||
|
st.children.remove(&ino);
|
||||||
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||||
|
}
|
||||||
|
// A create (or move-in) shows up as a change to the parent listing;
|
||||||
|
// drop it so the new child is picked up on the next readdir.
|
||||||
|
if let Some(parent_uid) = parent_node_uid
|
||||||
|
&& let Some(&parent) = st.by_uid.get(parent_uid)
|
||||||
|
&& st.children.remove(&parent).is_some()
|
||||||
|
{
|
||||||
|
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||||
|
let mut st = state.lock().unwrap();
|
||||||
|
// Capture the inode before `forget` clears the uid mapping.
|
||||||
|
let child = st.by_uid.get(node_uid).copied();
|
||||||
|
if let Some((parent, name)) = st.forget(node_uid) {
|
||||||
|
match child {
|
||||||
|
Some(child) => {
|
||||||
|
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Continuity or scope was lost: our cached listings may be arbitrarily
|
||||||
|
// stale, so drop every listing and tell the kernel to forget all
|
||||||
|
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
||||||
|
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||||
|
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
||||||
|
let mut st = state.lock().unwrap();
|
||||||
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
|
st.children.clear();
|
||||||
|
for ino in dirs {
|
||||||
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No substantive local change; the cursor advance is handled by the
|
||||||
|
// caller persisting the event id.
|
||||||
|
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the remote event cursor forever, applying each batch to the shared
|
||||||
|
/// state. Seeds the cursor from the current server head so we only react to
|
||||||
|
/// changes made after mount. Runs as a Tokio task; returns only on fatal error.
|
||||||
|
async fn run_event_sync(
|
||||||
|
client: ProtonDriveClient,
|
||||||
|
scope: DriveEventScopeId,
|
||||||
|
state: Arc<Mutex<State>>,
|
||||||
|
notifier: Notifier,
|
||||||
|
) {
|
||||||
|
// Seed: a `None` cursor yields a single `CursorAdvanced` at the server head.
|
||||||
|
let mut cursor: Option<DriveEventId> = match client.enumerate_events(&scope, None).await {
|
||||||
|
Ok(events) => events.last().map(|e| e.id().clone()),
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to seed event cursor; live sync disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!(?cursor, "event sync started");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
|
let events = match client.enumerate_events(&scope, cursor.as_ref()).await {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "event poll failed; retrying after interval");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if events.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debug!(count = events.len(), "applying remote events");
|
||||||
|
for event in &events {
|
||||||
|
apply_event(&state, ¬ifier, event);
|
||||||
|
}
|
||||||
|
cursor = events.last().map(|e| e.id().clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mount the filesystem at `mountpoint` and block until it is unmounted.
|
||||||
|
///
|
||||||
|
/// Fetches the My Files root up front (so an auth/network failure surfaces
|
||||||
|
/// before the kernel mount), then spawns the Phase 2 event-sync task and takes
|
||||||
|
/// over the calling thread in the FUSE session loop. `rt` must be a handle to a
|
||||||
|
/// *running* multi-threaded runtime.
|
||||||
|
pub fn mount(
|
||||||
|
client: ProtonDriveClient,
|
||||||
|
rt: tokio::runtime::Handle,
|
||||||
|
mountpoint: &Path,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let root = rt
|
||||||
|
.block_on(client.get_my_files_folder())
|
||||||
|
.map_err(|e| std::io::Error::other(format!("fetch My Files root: {e}")))?;
|
||||||
|
let scope = root.tree_event_scope_id();
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.mount_options = vec![
|
||||||
|
MountOption::FSName("protondrive".to_string()),
|
||||||
|
MountOption::Subtype("protondrive".to_string()),
|
||||||
|
MountOption::RO,
|
||||||
|
MountOption::NoExec,
|
||||||
|
MountOption::DefaultPermissions,
|
||||||
|
];
|
||||||
|
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
|
||||||
|
|
||||||
|
let fs = ProtonFs::new(client.clone(), rt.clone(), root);
|
||||||
|
let shared = fs.shared_state();
|
||||||
|
|
||||||
|
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
|
||||||
|
// for the event task. `spawn` runs the session loop on its own thread;
|
||||||
|
// `join` then blocks here until the filesystem is unmounted, preserving
|
||||||
|
// `mount`'s blocking contract.
|
||||||
|
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||||
|
let notifier = session.notifier();
|
||||||
|
rt.spawn(run_event_sync(client, scope, shared, notifier));
|
||||||
|
|
||||||
|
session.join()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user