DB: Add client/user management primitives

This commit is contained in:
Félix Baylac-Jacqué 2022-07-28 12:34:17 +02:00
parent 87e10be295
commit ed7e184bf2
No known key found for this signature in database
GPG Key ID: EFD315F31848DBA4
10 changed files with 377 additions and 45 deletions

11
Cargo.lock generated
View File

@ -168,7 +168,6 @@ dependencies = [
"rusqlite_migration",
"serde",
"serde_json",
"serde_rusqlite",
"ureq",
]
@ -963,16 +962,6 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_rusqlite"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e73509831d054bb2d2a69e3457ad7c417a5820bab80a70676182148e29a503ed"
dependencies = [
"rusqlite",
"serde",
]
[[package]]
name = "slab"
version = "0.4.6"

View File

@ -11,5 +11,4 @@ rusqlite = "*"
rusqlite_migration = "*"
serde = {version = "*", features = ["derive"]}
serde_json = "*"
serde_rusqlite = "*"
ureq = {version = "*", features = ["json", "native-certs", "gzip"]}

View File

@ -4,6 +4,7 @@ pkgs.mkShell {
nativeBuildInputs = [
pkgs.rustc
pkgs.cargo
pkgs.clippy
pkgs.rust-analyzer
pkgs.pkg-config
pkgs.gtk4

View File

@ -1,40 +1,325 @@
use std::path::PathBuf;
use dirs;
use rusqlite::{Connection, Error};
use rusqlite::{Connection, named_params};
use rusqlite_migration::{Migrations, M};
pub fn open_db() -> Result<Connection,Error> {
use crate::mastodon::oauth::RegisteredApp;
use crate::mastodon::accounts::UserAccount;
#[derive(Debug)]
pub enum OpenDbError {
RusqliteError(rusqlite::Error),
IOError(std::io::Error)
}
pub fn open_db() -> Result<Connection,OpenDbError> {
let mut data_dir = dirs::data_dir();
let path = data_dir.get_or_insert(PathBuf::from("./"));
path.push("federatz");
std::fs::create_dir_all(&path);
std::fs::create_dir_all(&path).map_err(|err| OpenDbError::IOError(err))?;
path.push("federatz.db");
Connection::open(path)
Connection::open(path).map_err(|err| OpenDbError::RusqliteError(err))
}
pub fn run_migrations(mut conn: Connection) -> Result<(), rusqlite_migration::Error> {
pub fn run_migrations(conn: &mut Connection) -> Result<(), rusqlite_migration::Error> {
let migrations =
Migrations::new(vec![
M::up(r#"
CREATE TABLE oauth_client(
id INTEGER PRIMARY KEY,
instance_uri TEXT NOT NULL,
instance_fqdn TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL
client_secret TEXT NOT NULL,
name TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
website TEXT
);
CREATE TABLE local_user(
id INTEGER,
token TEXT NOT NULL,
username TEXT NOT NULL,
display_name TEXT NOT NULL,
locked BOOLEAN NOT NULL,
bot BOOLEAN NOT NULL,
created_at TEXT NOT NULL,
note TEXT NOT NULL,
follower_count INTEGER NOT NULL,
url TEXT NOT NULL,
avatar TEXT NOT NULL,
avatar_static TEXT NOT NULL,
header TEXT NOT NULL,
header_static TEXT NOT NULL,
followers_count INTEGER NOT NULL,
following_count INTEGER NOT NULL,
statuses_count INTEGER NOT NULL,
client INTEGER,
FOREIGN KEY(client) REFERENCES oauth_client(id)
);
);
"#)
]);
migrations.to_latest(&mut conn)
migrations.to_latest(conn)
}
#[derive(Debug, PartialEq)]
pub enum OauthClientDatabaseError {
NoClient,
RusqliteError(rusqlite::Error)
}
impl From<rusqlite::Error> for OauthClientDatabaseError {
fn from(error: rusqlite::Error) -> Self {
OauthClientDatabaseError::RusqliteError(error)
}
}
pub fn new_oauth_client(conn: &Connection, client: &RegisteredApp) -> Result<i64,rusqlite::Error> {
let mut stmt =
conn.prepare(r#"
INSERT INTO oauth_client
(instance_fqdn, client_id, client_secret, name, redirect_uri, website)
VALUES (:instance_fqdn, :client_id, :client_secret, :name, :redirect_uri, :website)"#
)?;
stmt.insert(
named_params! {
":instance_fqdn": client.instance_fqdn,
":client_id": client.client_id,
":client_secret": client.client_secret,
":name": client.name,
":redirect_uri": client.redirect_uri,
":website": client.website,
}
)
}
pub fn delete_oauth_client(conn: &Connection, client: &RegisteredApp) -> Result<usize,rusqlite::Error> {
let mut stmt =
conn.prepare(r#"
DELETE FROM oauth_client
WHERE client_id = :client_id AND client_secret = :client_secret"#
)?;
stmt.execute(
named_params! {
":client_id": client.client_id,
":client_secret": client.client_secret
}
)
}
pub fn get_oauth_client(conn: &Connection, client_id: &str) -> Result<(RegisteredApp, i64), OauthClientDatabaseError> {
let mut stmt =
conn.prepare(
r#"SELECT ROWID, instance_fqdn, client_id, client_secret, name, redirect_uri, website
FROM oauth_client
WHERE client_id = :client_id"#)?;
let mut rows = stmt.query_and_then(
named_params! {
":client_id": client_id
},
|row| Ok((RegisteredApp {
instance_fqdn: row.get("instance_fqdn")?,
client_id: row.get("client_id")?,
client_secret: row.get("client_secret")?,
name: row.get("name")?,
redirect_uri: row.get("redirect_uri")?,
website: row.get("website")?,
}, row.get("ROWID")?)))?;
rows.next().ok_or(OauthClientDatabaseError::NoClient)?
}
#[derive(Debug, PartialEq)]
pub enum GetLocalUserDatabaseError {
NoUser,
ManyUsers,
RusqliteError(rusqlite::Error)
}
impl From<rusqlite::Error> for GetLocalUserDatabaseError {
fn from(error: rusqlite::Error) -> Self {
GetLocalUserDatabaseError::RusqliteError(error)
}
}
#[derive(Debug, PartialEq)]
pub enum NewLocalUserDatabaseError {
ClientError(OauthClientDatabaseError),
RusqliteError(rusqlite::Error)
}
impl From<rusqlite::Error> for NewLocalUserDatabaseError {
fn from(error: rusqlite::Error) -> Self {
NewLocalUserDatabaseError::RusqliteError(error)
}
}
impl From<OauthClientDatabaseError> for NewLocalUserDatabaseError {
fn from(error:OauthClientDatabaseError) -> Self {
NewLocalUserDatabaseError::ClientError(error)
}
}
/// Creates a new user account and returns its Sqlite ROWID.
pub fn new_local_user(conn: &Connection, user: &UserAccount, client: &RegisteredApp) -> Result<i64, NewLocalUserDatabaseError> {
let mut stmt =
conn.prepare(r#"
INSERT INTO local_user (id, token, username, display_name, locked, bot, created_at, note, url, avatar,
avatar_static, header, header_static, followers_count, following_count,
statuses_count, client)
VALUES (:id, :token, :username, :display_name, :locked, :bot, :created_at, :note, :url, :avatar,
:avatar_static, :header, :header_static, :followers_count, :following_count,
:statuses_count, :client)"#
)?;
let client = get_oauth_client(&conn, &client.client_id)?;
let new_user = stmt.insert(
named_params! {
":id": user.id,
":token": user.token,
":username": user.username,
":display_name": user.display_name,
":locked": user.locked,
":bot": user.bot,
":created_at": user.created_at,
":note": user.note,
":url": user.url,
":avatar": user.avatar,
":avatar_static": user.avatar_static,
":header": user.header,
":header_static": user.header_static,
":followers_count": user.followers_count,
":following_count": user.following_count,
":statuses_count": user.statuses_count,
}
)?;
Ok(new_user)
}
pub fn get_local_user(conn: &Connection) -> Result<UserAccount, GetLocalUserDatabaseError> {
let mut stmt =
conn.prepare(r#"
SELECT CAST(id AS TEXT) as id, token, username, display_name, locked, bot, created_at, note, url, avatar,
avatar_static, header, header_static, followers_count, following_count,
statuses_count FROM local_user"#).map_err(|err| GetLocalUserDatabaseError::RusqliteError(err))?;
let mut rows = stmt.query_and_then(
[],
|row| Ok(UserAccount {
id: row.get("id")?,
token: row.get("token")?,
username: row.get("username")?,
display_name: row.get("display_name")?,
locked: row.get("locked")?,
bot: row.get("bot")?,
created_at: row.get("created_at")?,
note: row.get("note")?,
url: row.get("url")?,
avatar: row.get("avatar")?,
avatar_static: row.get("avatar_static")?,
header: row.get("header")?,
header_static: row.get("header_static")?,
followers_count: row.get("followers_count")?,
following_count: row.get("following_count")?,
statuses_count: row.get("statuses_count")?,
})).map_err(|err| GetLocalUserDatabaseError::RusqliteError(err))?;
let user = rows.next().ok_or(GetLocalUserDatabaseError::NoUser)?;
if rows.next().is_none() {
user
} else {
Err(GetLocalUserDatabaseError::ManyUsers)
}
}
pub fn delete_local_user(conn: &Connection, user: &UserAccount) -> Result<usize, rusqlite::Error> {
let mut stmt =
conn.prepare("DELETE FROM local_user WHERE username = :username")?;
stmt.execute(named_params! { ":username": user.username })
}
#[cfg(test)]
mod test {
use crate::db::{UserAccount, RegisteredApp};
fn dummy_client () -> RegisteredApp {
RegisteredApp {
instance_fqdn: String::from("dummy.instance"),
client_id: String::from("dummy-client-id"),
client_secret: String::from("dummy-client-secret"),
name: String::from("dummy-name"),
redirect_uri: String::from("dummy-redirect-uri"),
website: None,
}
}
fn dummy_local_user (id: &str) -> UserAccount {
UserAccount {
id: String::from(id),
token: String::from("dummy-token"),
username: String::from(format!("Ninjatrappeur{}", id)),
display_name: String::from("⠴Ninjatrappeur⠦"),
locked: false,
bot: false,
created_at: String::from("2018-05-26T13:07:04.000Z"),
note: String::from("dummy-note"),
url: String::from("https://social.alternativebit.fr/users/Ninjatrappeur"),
avatar: String::from("https://social.alternativebit.fr/media/3f9ad2d6f473c954506f404de5a3636905035f32ec9f1f07deca0a72e88ac3e8.blob"),
avatar_static: String::from("https://social.alternativebit.fr/media/3f9ad2d6f473c954506f404de5a3636905035f32ec9f1f07deca0a72e88ac3e8.blob"),
header: String::from("https://social.alternativebit.fr/media/b5f2cf7e-7892-4692-b0c8-157818ad1b87/57CF8FB0FE2143FF1267A90BD3217D8D433FA2381C56E36264352A3EA0F17838.png"),
header_static: String::from("https://social.alternativebit.fr/media/b5f2cf7e-7892-4692-b0c8-157818ad1b87/57CF8FB0FE2143FF1267A90BD3217D8D433FA2381C56E36264352A3EA0F17838.png"),
followers_count: 615,
following_count: 191,
statuses_count: 1297,
}
}
fn setup_temp_db () -> rusqlite::Connection {
let mut conn = rusqlite::Connection::open_in_memory().unwrap();
super::run_migrations(&mut conn).unwrap();
conn
}
#[test]
fn new_oauth_client () {
let conn = setup_temp_db();
let dummy_client = dummy_client();
super::new_oauth_client(&conn, &dummy_client).unwrap();
}
#[test]
fn get_oauth_client () {
let conn = setup_temp_db();
let dummy_client = dummy_client();
super::new_oauth_client(&conn, &dummy_client).unwrap();
assert_eq!(super::get_oauth_client(&conn, &dummy_client.client_id).unwrap().0, dummy_client);
}
#[test]
fn new_local_user () {
let conn = setup_temp_db();
let dummy_client = dummy_client();
let dummy_local_user = dummy_local_user("1");
super::new_oauth_client(&conn, &dummy_client).unwrap();
super::new_local_user(&conn, &dummy_local_user, &dummy_client).unwrap();
}
#[test]
fn get_local_user_ok () {
let conn = setup_temp_db();
let dummy_client = dummy_client();
let dummy_local_user = dummy_local_user("1");
super::new_oauth_client(&conn, &dummy_client).unwrap();
super::new_local_user(&conn, &dummy_local_user, &dummy_client).unwrap();
assert_eq!(super::get_local_user(&conn), Ok(dummy_local_user));
}
#[test]
fn get_local_user_fail_many () {
let conn = setup_temp_db();
let dummy_client = dummy_client();
let dummy_local_user_1 = dummy_local_user("1");
let dummy_local_user_2 = dummy_local_user("2");
super::new_oauth_client(&conn, &dummy_client).unwrap();
super::new_local_user(&conn, &dummy_local_user_1, &dummy_client).unwrap();
super::new_local_user(&conn, &dummy_local_user_2, &dummy_client).unwrap();
assert_eq!(super::get_local_user(&conn), Err(super::GetLocalUserDatabaseError::ManyUsers));
}
#[test]
fn get_local_user_fail_none () {
let conn = setup_temp_db();
assert_eq!(super::get_local_user(&conn), Err(super::GetLocalUserDatabaseError::NoUser));
}
}

View File

@ -2,7 +2,7 @@ use gtk4 as gtk;
use gtk::prelude::*;
use gtk::Application;
mod oauth;
mod mastodon;
mod ui;
mod db;
@ -11,8 +11,8 @@ use ui::widgets::oauth::create_oauth_assistant;
fn main() {
let conn = db::open_db();
match conn {
Ok(conn) => db::run_migrations(conn),
Err(e) => panic!("Error when running the DB migrations: {}", e),
Ok(mut conn) => db::run_migrations(&mut conn),
Err(e) => panic!("Error when running the DB migrations: {:?}", e),
};
let app = Application::builder()
.application_id("fr.alternativebit.federatz")

View File

@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
use crate::mastodon;
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct UserAccount {
pub id: String,
pub token: String,
pub username: String,
pub display_name: String,
pub locked: bool,
pub bot: bool,
pub created_at: String,
pub note: String,
pub url: String,
pub avatar: String,
pub avatar_static: String,
pub header: String,
pub header_static: String,
pub followers_count: u32,
pub following_count: u32,
pub statuses_count: u32,
}
pub fn verify_credentials(instance_fqdn: &str, authorization_token: &str) -> Result<UserAccount, mastodon::RequestError> {
let resp: Result<ureq::Response, ureq::Error> = ureq::get(&format!("https://{}/api/v1/accounts/verify_credentials", instance_fqdn))
.set("Authorization", authorization_token)
.call();
match resp {
Ok(resp) =>
resp.into_json().map_err(mastodon::RequestError::JsonError),
Err(ureq::Error::Status(code, response)) =>
Err(mastodon::RequestError::HttpError(code, response.status_text().to_string())),
Err(_) =>
Err(mastodon::RequestError::HttpError(0, "Transport error".to_string())),
}
}

10
src/mastodon/mod.rs Normal file
View File

@ -0,0 +1,10 @@
use std::io::Error;
pub mod accounts;
pub mod oauth;
#[derive(Debug)]
pub enum RequestError {
HttpError(u16, String),
JsonError(Error)
}

View File

@ -1,36 +1,30 @@
use std::io::Error;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Clone)]
use crate::mastodon;
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct RegisteredApp {
pub instance_fqdn: String,
pub client_id: String,
pub client_secret: String,
pub id: String,
pub name: String,
pub redirect_uri: String,
pub website: Option<String>
}
#[derive(Debug)]
pub enum RequestError {
HttpError(u16, String),
JsonError(Error)
}
pub fn register_app(instance_fqdn: &str, app_name: &str) -> Result<RegisteredApp, RequestError> {
pub fn register_app(instance_fqdn: &str, app_name: &str) -> Result<RegisteredApp, mastodon::RequestError> {
let resp: Result<ureq::Response, ureq::Error> = ureq::post(&format!("https://{}/api/v1/apps", instance_fqdn))
.send_form(
&[("client_name", app_name),
("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"),
("scopes", "read write push")]);
let io_error_to_request_error = |err| RequestError::JsonError(err);
match resp {
Ok(resp) =>
resp.into_json().map_err(io_error_to_request_error),
resp.into_json().map_err(mastodon::RequestError::JsonError),
Err(ureq::Error::Status(code, response)) =>
Err(RequestError::HttpError(code,response.status_text().to_string())),
Err(mastodon::RequestError::HttpError(code,response.status_text().to_string())),
Err(_) =>
Err(RequestError::HttpError(0, "Transport error".to_string()))
Err(mastodon::RequestError::HttpError(0, "Transport error".to_string()))
}
}

View File

@ -7,7 +7,7 @@ mod page1;
mod page2;
mod page3;
use crate::oauth;
use crate::mastodon::{accounts, oauth};
pub fn create_oauth_assistant(app: &gtk::Application) -> gtk::Assistant {
let assistant = gtk::Assistant::builder()
@ -32,7 +32,6 @@ pub fn create_oauth_assistant(app: &gtk::Application) -> gtk::Assistant {
let ores = oauth::register_app(&instance_uri, "federatz-0.1");
match ores {
Ok(res) => {
println!("{:?}", res);
assistant.set_page_complete(&page1, true);
assistant.next_page();
let auth_link = oauth::gen_authorize_url(&instance_uri, &res);
@ -45,5 +44,20 @@ pub fn create_oauth_assistant(app: &gtk::Application) -> gtk::Assistant {
}
}));
page2.imp().authorization_token_entry.connect_activate(
glib::clone!(@weak page2, @weak page1, @weak assistant => move |_| {
let instance_uri = page1.imp().instance_uri_text_entry.buffer().text();
let authorization_token = page2.imp().authorization_token_entry.buffer().text();
match accounts::verify_credentials(&instance_uri, &authorization_token) {
Ok(res) => {
assistant.set_page_complete(&page2, true);
assistant.next_page();
// TODO: save to DB
},
Err(err) => println!("Error: {:?}", err),
}
}));
assistant
}

View File

@ -2,7 +2,7 @@ use gtk4 as gtk;
use gtk::subclass::prelude::*;
use gtk::subclass::window::WindowImpl;
use gtk::prelude::*;
use gtk::{glib, CompositeTemplate, Box, Label};
use gtk::{glib, CompositeTemplate, Box, Label, Entry};
use glib::subclass::InitializingObject;
#[derive(Debug, Default, CompositeTemplate)]
@ -10,6 +10,8 @@ use glib::subclass::InitializingObject;
pub struct OauthAssistantPage2 {
#[template_child]
pub authorization_link_label: TemplateChild<Label>,
#[template_child]
pub authorization_token_entry: TemplateChild<Entry>,
}
#[glib::object_subclass]