use std::path::Path;
use std::sync::Arc;
use rusqlite::{Connection, OptionalExtension, params};
use crate::db::{self, Db};
use crate::error::AppError;
use crate::storage::FileScope;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LibraryRef {
User(u64),
Group(u64),
}
impl LibraryRef {
pub fn parse(name: &str) -> Option<Self> {
let (kind, id) = name.split_at_checked(1)?;
let id = id.parse().ok().filter(|_| !id.starts_with('+'))?;
match kind {
"u" => Some(LibraryRef::User(id)),
"g" => Some(LibraryRef::Group(id)),
_ => None,
}
}
}
impl std::fmt::Display for LibraryRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LibraryRef::User(id) => write!(f, "u{id}"),
LibraryRef::Group(id) => write!(f, "g{id}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: u64,
pub orcid: Option<String>,
pub username: String,
pub display_name: Option<String>,
pub profile_hidden: bool,
pub disabled: bool,
pub legacy_files: bool,
pub quota_bytes: Option<i64>,
}
impl User {
pub fn name(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.username)
}
pub fn files(&self) -> FileScope {
if self.legacy_files {
FileScope::Legacy
} else {
FileScope::User(self.id)
}
}
}
pub fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
pub fn open_connection(path: &Path) -> rusqlite::Result<Connection> {
let mut conn = Connection::open(path)?;
conn.busy_timeout(std::time::Duration::from_secs(30))?;
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?;
db::migrate(&mut conn, db::CONTROL)?;
Ok(conn)
}
const USER_COLUMNS: &str =
"id, orcid, username, display_name, profile_hidden, disabled, files_layout, quota_bytes";
fn user_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<User> {
Ok(User {
id: r.get::<_, i64>(0)? as u64,
orcid: r.get(1)?,
username: r.get(2)?,
display_name: r.get(3)?,
profile_hidden: r.get(4)?,
disabled: r.get(5)?,
legacy_files: r.get::<_, String>(6)? == "legacy",
quota_bytes: r.get(7)?,
})
}
pub fn user(conn: &Connection, id: u64) -> rusqlite::Result<Option<User>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM user WHERE id = ?1"),
[id as i64],
user_row,
)
.optional()
}
pub fn user_by_orcid(conn: &Connection, orcid: &str) -> rusqlite::Result<Option<User>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM user WHERE orcid = ?1"),
[orcid],
user_row,
)
.optional()
}
pub fn insert_user(conn: &Connection, user: &User) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO user (id, orcid, username, display_name, profile_hidden, disabled,
files_layout, quota_bytes, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
user.id as i64,
user.orcid,
user.username,
user.display_name,
user.profile_hidden,
user.disabled,
if user.legacy_files { "legacy" } else { "user" },
user.quota_bytes,
now(),
],
)?;
Ok(())
}
pub fn key_hash(key: &str) -> String {
aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, key.as_bytes())
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
const KEY_USE_PRECISION: i64 = 3600;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invite {
pub orcid: String,
pub note: Option<String>,
pub created_at: i64,
pub used_by: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiKey {
pub id: i64,
pub prefix: String,
pub name: String,
pub write: bool,
pub created_at: i64,
pub last_used_at: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyOwner {
pub key_id: i64,
pub user_id: u64,
pub write: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignIn {
User(User),
Disabled,
NotInvited,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserOverview {
pub user: User,
pub bytes_used: i64,
pub keys: i64,
}
pub fn user_count(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT count(*) FROM user", [], |r| r.get(0))
}
pub fn libraries(conn: &Connection) -> rusqlite::Result<Vec<(LibraryRef, FileScope)>> {
let mut stmt = conn.prepare("SELECT id, files_layout FROM user ORDER BY id")?;
let rows = stmt.query_map([], |r| {
let id = r.get::<_, i64>(0)? as u64;
let legacy = r.get::<_, String>(1)? == "legacy";
Ok((
LibraryRef::User(id),
if legacy {
FileScope::Legacy
} else {
FileScope::User(id)
},
))
})?;
rows.collect()
}
pub fn version_high(conn: &Connection, library: LibraryRef) -> rusqlite::Result<i64> {
Ok(conn
.query_row(
"SELECT version_high FROM library_state WHERE library = ?1",
[library.to_string()],
|r| r.get(0),
)
.optional()?
.unwrap_or(0))
}
pub fn note_version(conn: &Connection, library: LibraryRef, version: i64) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO library_state (library, version_high) VALUES (?1, ?2)
ON CONFLICT (library) DO UPDATE SET version_high = excluded.version_high
WHERE excluded.version_high > library_state.version_high",
params![library.to_string(), version],
)?;
Ok(())
}
pub fn note_usage(conn: &Connection, library: LibraryRef, bytes: i64) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO library_state (library, bytes_used) VALUES (?1, ?2)
ON CONFLICT (library) DO UPDATE SET bytes_used = excluded.bytes_used
WHERE excluded.bytes_used != library_state.bytes_used",
params![library.to_string(), bytes],
)?;
Ok(())
}
const ALLOWED_SEEDED: &str = "allowed_orcids_invited";
pub struct Control {
pub db: Arc<Db>,
}
impl Control {
pub fn open(path: &Path) -> Result<Self, AppError> {
Ok(Control {
db: Db::open_with(path, db::CONTROL, 4)?,
})
}
pub async fn server_id(&self) -> Result<String, AppError> {
self.db.server_id().await
}
pub async fn user(&self, id: u64) -> Result<Option<User>, AppError> {
self.db.read(move |c| user(c, id)).await
}
pub async fn ensure_owner(&self, owner: User) -> Result<(), AppError> {
let id = owner.id;
let outcome = self
.db
.write(move |c| {
let tx = c.transaction()?;
if user(&tx, id)?.is_some() {
return Ok(None);
}
if user_count(&tx)? > 0 {
let ids: Vec<i64> = tx
.prepare("SELECT id FROM user ORDER BY id")?
.query_map([], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
return Ok(Some(ids));
}
insert_user(&tx, &owner)?;
tx.commit()?;
Ok(None)
})
.await?;
match outcome {
None => Ok(()),
Some(ids) => Err(AppError::Internal(format!(
"user {id} (DEFTER_USER_ID) is not among this server's users ({ids:?})"
))),
}
}
pub async fn set_profile_hidden(&self, id: u64, hidden: bool) -> Result<(), AppError> {
self.db
.write(move |c| {
c.execute(
"UPDATE user SET profile_hidden = ?2 WHERE id = ?1",
params![id as i64, hidden],
)?;
Ok(())
})
.await
}
pub async fn meta(&self, key: &'static str) -> Result<Option<String>, AppError> {
self.db.read(move |c| db::get_meta(c, key)).await
}
pub async fn set_meta(&self, values: Vec<(&'static str, String)>) -> Result<(), AppError> {
self.db
.write(move |c| {
let tx = c.transaction()?;
for (key, value) in &values {
db::set_meta(&tx, key, value)?;
}
tx.commit()
})
.await
}
pub async fn user_by_orcid(&self, orcid: &str) -> Result<Option<User>, AppError> {
let orcid = orcid.to_string();
self.db.read(move |c| user_by_orcid(c, &orcid)).await
}
pub async fn reconcile(
&self,
owner: u64,
owner_orcid: Option<String>,
allowed: Vec<String>,
admins: bool,
) -> Result<(), AppError> {
let outcome = self
.db
.write(move |c| {
let tx = c.transaction()?;
let owner_has_orcid = user(&tx, owner)?.is_some_and(|u| u.orcid.is_some());
let owner_orcid = match (&owner_orcid, allowed.as_slice()) {
(Some(orcid), _) => Some(orcid.clone()),
(None, [only]) if !admins && !owner_has_orcid => Some(only.clone()),
_ => None,
};
if let Some(orcid) = &owner_orcid {
match user_by_orcid(&tx, orcid)? {
Some(user) if user.id != owner => return Ok(Some(user.id)),
Some(_) => {}
None => {
tx.execute(
"UPDATE user SET orcid = ?2 WHERE id = ?1",
params![owner as i64, orcid],
)?;
}
}
}
if db::get_meta(&tx, ALLOWED_SEEDED)?.is_none() {
for orcid in &allowed {
if user_by_orcid(&tx, orcid)?.is_none() {
tx.execute(
"INSERT OR IGNORE INTO invite (orcid, invited_by, note, created_at)
VALUES (?1, ?2, 'allowed to sign in by the settings', ?3)",
params![orcid, owner as i64, now()],
)?;
}
}
db::set_meta(&tx, ALLOWED_SEEDED, "1")?;
}
tx.commit()?;
Ok(None)
})
.await?;
match outcome {
None => Ok(()),
Some(other) => Err(AppError::Internal(format!(
"--orcid belongs to user {other}, not to the owner"
))),
}
}
pub async fn sign_in(&self, orcid: &str, admin: bool, owner: u64) -> Result<SignIn, AppError> {
let orcid = orcid.to_string();
self.db
.write(move |c| {
let tx = c.transaction()?;
if let Some(user) = user_by_orcid(&tx, &orcid)? {
return Ok(if user.disabled {
SignIn::Disabled
} else {
SignIn::User(user)
});
}
if admin
&& let Some(mut owner) = user(&tx, owner)?
&& owner.orcid.is_none()
{
tx.execute(
"UPDATE user SET orcid = ?2 WHERE id = ?1",
params![owner.id as i64, orcid],
)?;
tx.commit()?;
owner.orcid = Some(orcid);
return Ok(if owner.disabled {
SignIn::Disabled
} else {
SignIn::User(owner)
});
}
let invited: bool = tx.query_row(
"SELECT count(*) > 0 FROM invite WHERE orcid = ?1 AND used_by IS NULL",
[&orcid],
|r| r.get(0),
)?;
if !invited && !admin {
return Ok(SignIn::NotInvited);
}
let id: i64 =
tx.query_row("SELECT coalesce(max(id), 0) + 1 FROM user", [], |r| {
r.get(0)
})?;
let user = User {
id: id as u64,
orcid: Some(orcid.clone()),
username: orcid.clone(),
display_name: None,
profile_hidden: true,
disabled: false,
legacy_files: false,
quota_bytes: None,
};
insert_user(&tx, &user)?;
tx.execute(
"UPDATE invite SET used_by = ?2, used_at = ?3 WHERE orcid = ?1",
params![orcid, id, now()],
)?;
tx.commit()?;
Ok(SignIn::User(user))
})
.await
}
pub async fn set_display_name(&self, id: u64, name: String) -> Result<(), AppError> {
self.db
.write(move |c| {
c.execute(
"UPDATE user SET display_name = ?2 WHERE id = ?1",
params![id as i64, name],
)?;
Ok(())
})
.await
}
pub async fn set_quota(&self, id: u64, quota_bytes: Option<i64>) -> Result<(), AppError> {
self.db
.write(move |c| {
c.execute(
"UPDATE user SET quota_bytes = ?2 WHERE id = ?1",
params![id as i64, quota_bytes],
)?;
Ok(())
})
.await
}
pub async fn set_disabled(&self, id: u64, disabled: bool) -> Result<(), AppError> {
self.db
.write(move |c| {
let tx = c.transaction()?;
tx.execute(
"UPDATE user SET disabled = ?2 WHERE id = ?1",
params![id as i64, disabled],
)?;
if disabled {
tx.execute(
"UPDATE api_key SET revoked_at = ?2
WHERE user_id = ?1 AND revoked_at IS NULL",
params![id as i64, now()],
)?;
}
tx.commit()
})
.await
}
pub async fn users(&self) -> Result<Vec<UserOverview>, AppError> {
self.db
.read(|c| {
let columns = USER_COLUMNS
.split(", ")
.map(|col| format!("u.{col}"))
.collect::<Vec<_>>()
.join(", ");
let mut stmt = c.prepare(&format!(
"SELECT {columns},
coalesce((SELECT bytes_used FROM library_state
WHERE library = 'u' || u.id), 0),
(SELECT count(*) FROM api_key k
WHERE k.user_id = u.id AND k.revoked_at IS NULL)
FROM user u ORDER BY u.id"
))?;
let rows = stmt.query_map([], |r| {
Ok(UserOverview {
user: user_row(r)?,
bytes_used: r.get(8)?,
keys: r.get(9)?,
})
})?;
rows.collect()
})
.await
}
pub async fn invites(&self) -> Result<Vec<Invite>, AppError> {
self.db
.read(|c| {
let mut stmt = c.prepare(
"SELECT orcid, note, created_at, used_by FROM invite
ORDER BY used_by IS NOT NULL, created_at DESC, orcid",
)?;
let rows = stmt.query_map([], |r| {
Ok(Invite {
orcid: r.get(0)?,
note: r.get(1)?,
created_at: r.get(2)?,
used_by: r.get::<_, Option<i64>>(3)?.map(|id| id as u64),
})
})?;
rows.collect()
})
.await
}
pub async fn invite(
&self,
orcid: String,
by: u64,
note: Option<String>,
) -> Result<bool, AppError> {
self.db
.write(move |c| {
let tx = c.transaction()?;
if user_by_orcid(&tx, &orcid)?.is_some() {
return Ok(false);
}
tx.execute(
"INSERT INTO invite (orcid, invited_by, note, created_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (orcid) DO UPDATE SET note = excluded.note
WHERE invite.used_by IS NULL",
params![orcid, by as i64, note, now()],
)?;
tx.commit()?;
Ok(true)
})
.await
}
pub async fn uninvite(&self, orcid: String) -> Result<(), AppError> {
self.db
.write(move |c| {
c.execute(
"DELETE FROM invite WHERE orcid = ?1 AND used_by IS NULL",
[orcid],
)?;
Ok(())
})
.await
}
pub async fn mint_key(
&self,
user_id: u64,
name: String,
write: bool,
) -> Result<String, AppError> {
let key = crate::auth::random_hex(32);
let (hash, prefix) = (key_hash(&key), key[..8].to_string());
self.db
.write(move |c| {
c.execute(
"INSERT INTO api_key (user_id, hash, prefix, name, write, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![user_id as i64, hash, prefix, name, write, now()],
)?;
Ok(())
})
.await?;
Ok(key)
}
pub async fn key_owner(&self, key: &str) -> Result<Option<KeyOwner>, AppError> {
let hash = key_hash(key);
let found = self
.db
.read(move |c| {
c.query_row(
"SELECT k.id, k.user_id, k.write, coalesce(k.last_used_at, 0)
FROM api_key k JOIN user u ON u.id = k.user_id
WHERE k.hash = ?1 AND k.revoked_at IS NULL AND u.disabled = 0",
[hash],
|r| {
Ok((
KeyOwner {
key_id: r.get(0)?,
user_id: r.get::<_, i64>(1)? as u64,
write: r.get(2)?,
},
r.get::<_, i64>(3)?,
))
},
)
.optional()
})
.await?;
let Some((owner, last_used)) = found else {
return Ok(None);
};
let at = now();
if at - last_used >= KEY_USE_PRECISION {
let id = owner.key_id;
self.db
.write(move |c| {
c.execute(
"UPDATE api_key SET last_used_at = ?2 WHERE id = ?1",
params![id, at],
)
})
.await?;
}
Ok(Some(owner))
}
pub async fn keys(&self, user_id: u64) -> Result<Vec<ApiKey>, AppError> {
self.db
.read(move |c| {
let mut stmt = c.prepare(
"SELECT id, prefix, name, write, created_at, last_used_at FROM api_key
WHERE user_id = ?1 AND revoked_at IS NULL ORDER BY created_at DESC, id DESC",
)?;
let rows = stmt.query_map([user_id as i64], |r| {
Ok(ApiKey {
id: r.get(0)?,
prefix: r.get(1)?,
name: r.get(2)?,
write: r.get(3)?,
created_at: r.get(4)?,
last_used_at: r.get(5)?,
})
})?;
rows.collect()
})
.await
}
pub async fn revoke_key(&self, user_id: u64, key_id: i64) -> Result<bool, AppError> {
self.db
.write(move |c| {
Ok(c.execute(
"UPDATE api_key SET revoked_at = ?3
WHERE id = ?1 AND user_id = ?2 AND revoked_at IS NULL",
params![key_id, user_id as i64, now()],
)? > 0)
})
.await
}
pub async fn note_usage(&self, library: LibraryRef, bytes: i64) -> Result<(), AppError> {
self.db.write(move |c| note_usage(c, library, bytes)).await
}
pub async fn version_high(&self, library: LibraryRef) -> Result<i64, AppError> {
self.db.read(move |c| version_high(c, library)).await
}
pub async fn note_version(&self, library: LibraryRef, version: i64) -> Result<(), AppError> {
self.db
.write(move |c| note_version(c, library, version))
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn owner(id: u64) -> User {
User {
id,
orcid: None,
username: "owner".into(),
display_name: None,
profile_hidden: false,
disabled: false,
legacy_files: false,
quota_bytes: None,
}
}
#[test]
fn names_libraries() {
assert_eq!(LibraryRef::User(12).to_string(), "u12");
assert_eq!(LibraryRef::parse("g3"), Some(LibraryRef::Group(3)));
assert_eq!(LibraryRef::parse("u12"), Some(LibraryRef::User(12)));
for bad in ["", "u", "x1", "u-1", "u+1", "u1.sqlite3", "control"] {
assert_eq!(LibraryRef::parse(bad), None, "{bad}");
}
}
#[tokio::test]
async fn keeps_users_and_what_libraries_served() {
let dir = tempfile::tempdir().unwrap();
let control = Control::open(&dir.path().join("control.sqlite3")).unwrap();
control.ensure_owner(owner(7)).await.unwrap();
control.ensure_owner(owner(7)).await.unwrap();
let refused = control.ensure_owner(owner(8)).await.unwrap_err();
assert!(refused.to_string().contains("[7]"), "{refused}");
let user = control.user(7).await.unwrap().unwrap();
assert_eq!(user.files(), FileScope::User(7));
control.set_profile_hidden(7, true).await.unwrap();
control
.reconcile(7, Some("0000-0003-1419-2405".into()), Vec::new(), false)
.await
.unwrap();
assert_eq!(control.user(7).await.unwrap().unwrap().name(), "owner");
control
.set_display_name(7, "Ada Example".into())
.await
.unwrap();
let user = control.user(7).await.unwrap().unwrap();
assert_eq!(user.name(), "Ada Example");
assert!(user.profile_hidden);
assert_eq!(user.orcid.as_deref(), Some("0000-0003-1419-2405"));
let library = LibraryRef::User(7);
assert_eq!(control.version_high(library).await.unwrap(), 0);
control.note_version(library, 5).await.unwrap();
control.note_version(library, 3).await.unwrap();
assert_eq!(control.version_high(library).await.unwrap(), 5);
}
#[tokio::test]
async fn counts_every_change() {
let dir = tempfile::tempdir().unwrap();
let control = Control::open(&dir.path().join("control.sqlite3")).unwrap();
let revision = || async {
control
.db
.read(|c| c.query_row("SELECT n FROM revision", [], |r| r.get::<_, i64>(0)))
.await
.unwrap()
};
let before = revision().await;
control.ensure_owner(owner(1)).await.unwrap();
control.note_version(LibraryRef::User(1), 2).await.unwrap();
control
.set_meta(vec![("schema", "{}".into())])
.await
.unwrap();
assert_eq!(revision().await, before + 3);
control.user(1).await.unwrap();
assert_eq!(revision().await, before + 3);
}
}