defter 0.5.1

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! The control database (`control.sqlite3`): the server's users, and what is
//! known about each library outside it. Each library's data is a database of its
//! own (see `registry`). The schema is `migrations/control`.

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;

/// A library: a user's own, or a group's.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LibraryRef {
    User(u64),
    Group(u64),
}

impl LibraryRef {
    /// `u<id>` or `g<id>`: the name of its database file and its backups.
    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}"),
        }
    }
}

/// A user of the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
    /// Their Zotero userID.
    pub id: u64,
    pub orcid: Option<String>,
    pub username: String,
    pub display_name: Option<String>,
    pub profile_hidden: bool,
    pub disabled: bool,
    /// Their files are under `files/`, from before there were several libraries.
    pub legacy_files: bool,
}

impl User {
    /// Where their library's attachment files are.
    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)
}

/// The control database at `path` (created if needed) as one plain connection,
/// for commands that run without the server, such as `defter migrate`.
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)
}

pub fn user(conn: &Connection, id: u64) -> rusqlite::Result<Option<User>> {
    conn.query_row(
        "SELECT id, orcid, username, display_name, profile_hidden, disabled, files_layout
         FROM user WHERE id = ?1",
        [id as i64],
        |r| {
            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",
            })
        },
    )
    .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, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
        params![
            user.id as i64,
            user.orcid,
            user.username,
            user.display_name,
            user.profile_hidden,
            user.disabled,
            if user.legacy_files { "legacy" } else { "user" },
            now(),
        ],
    )?;
    Ok(())
}

pub fn user_count(conn: &Connection) -> rusqlite::Result<i64> {
    conn.query_row("SELECT count(*) FROM user", [], |r| r.get(0))
}

/// Every library there is, with where its files are: the users' own.
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()
}

/// The highest version the library has served, 0 if none is known.
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 struct Control {
    pub db: Arc<Db>,
}

impl Control {
    /// Open (creating if needed) the control database at `path`.
    pub fn open(path: &Path) -> Result<Self, AppError> {
        Ok(Control {
            db: Db::open_with(path, db::CONTROL, 4)?,
        })
    }

    /// The installation's stable `Zotero-Server-ID`.
    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
    }

    /// Make `owner` the first user of a new server; anything else is refused
    /// once there are users, so a changed `--user-id` cannot quietly start an
    /// empty library.
    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 set_orcid(&self, id: u64, orcid: String) -> Result<(), AppError> {
        self.db
            .write(move |c| {
                c.execute(
                    "UPDATE user SET orcid = ?2 WHERE id = ?1",
                    params![id as i64, orcid],
                )?;
                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 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,
        }
    }

    #[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();
        // Again is fine; another owner once there are users is not.
        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
            .set_orcid(7, "0000-0003-1419-2405".into())
            .await
            .unwrap();
        let user = control.user(7).await.unwrap().unwrap();
        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();
        // It never goes down.
        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);
        // Reading changes nothing.
        control.user(1).await.unwrap();
        assert_eq!(revision().await, before + 3);
    }
}