From 33ba576d4a030358da91e11093f22c9828d452e4 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 26 Apr 2025 16:27:18 -0400 Subject: [PATCH] add: profile connections, spotify connection --- Cargo.lock | 9 +- crates/app/Cargo.toml | 2 +- crates/app/src/assets.rs | 17 + crates/app/src/langs/en-US.toml | 1 + .../app/src/public/html/auth/connection.html | 40 +++ crates/app/src/public/html/components.html | 48 ++- crates/app/src/public/html/profile/base.html | 7 + .../app/src/public/html/profile/settings.html | 45 +++ crates/app/src/public/html/root.html | 58 +++- .../app/src/public/images/vendor/spotify.svg | 2 + crates/app/src/public/js/atto.js | 46 ++- crates/app/src/public/js/me.js | 295 ++++++++++++++++++ .../src/routes/api/v1/auth/connections/mod.rs | 156 +++++++++ .../routes/api/v1/auth/connections/spotify.rs | 42 +++ crates/app/src/routes/api/v1/auth/mod.rs | 1 + crates/app/src/routes/api/v1/auth/profile.rs | 14 + crates/app/src/routes/api/v1/mod.rs | 22 ++ crates/app/src/routes/pages/auth.rs | 25 +- crates/app/src/routes/pages/mod.rs | 4 + crates/core/Cargo.toml | 3 +- crates/core/src/config.rs | 22 ++ crates/core/src/database/auth.rs | 8 +- crates/core/src/database/connections/mod.rs | 1 + .../core/src/database/connections/spotify.rs | 21 ++ .../src/database/drivers/sql/create_users.sql | 3 +- crates/core/src/database/mod.rs | 5 +- crates/core/src/model/auth.rs | 45 +++ crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- example/tetratto.toml | 2 +- sql_changes/users_connections.sql | 2 + 31 files changed, 931 insertions(+), 19 deletions(-) create mode 100644 crates/app/src/public/html/auth/connection.html create mode 100644 crates/app/src/public/images/vendor/spotify.svg create mode 100644 crates/app/src/routes/api/v1/auth/connections/mod.rs create mode 100644 crates/app/src/routes/api/v1/auth/connections/spotify.rs create mode 100644 crates/core/src/database/connections/mod.rs create mode 100644 crates/core/src/database/connections/spotify.rs create mode 100644 sql_changes/users_connections.sql diff --git a/Cargo.lock b/Cargo.lock index 39b066f..93d7df8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3218,7 +3218,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "1.0.7" +version = "1.0.8" dependencies = [ "ammonia", "axum", @@ -3244,13 +3244,14 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "1.0.7" +version = "1.0.8" dependencies = [ "async-recursion", "bb8-postgres", "bitflags 2.9.0", "pathbufd", "redis", + "reqwest", "rusqlite", "serde", "serde_json", @@ -3263,7 +3264,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "1.0.7" +version = "1.0.8" dependencies = [ "pathbufd", "serde", @@ -3272,7 +3273,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "1.0.7" +version = "1.0.8" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index a9edbe5..3732142 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "1.0.7" +version = "1.0.8" edition = "2024" [features] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 80e79ff..b52bdbb 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -44,6 +44,7 @@ pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.html") pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.html"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.html"); pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html"); +pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.html"); pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.html"); pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.html"); @@ -94,6 +95,8 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); // ... +pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg"); + /// A container for all loaded icons. pub(crate) static ICONS: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); @@ -120,6 +123,16 @@ pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) { writer.insert(icon.to_string(), svg); } +macro_rules! vendor_icon { + ($name:literal, $icon:ident, $icons_dir:expr) => {{ + let writer = &mut ICONS.write().await; + writer.insert($name.to_string(), $icon.to_string()); + + let file_path = PathBufD::current().extend(&[$icons_dir.clone(), $name.to_string()]); + std::fs::write(file_path, $icon).unwrap(); + }}; +} + /// Read a string and replace all custom blocks with the corresponding correct HTML. /// /// # Replaces @@ -179,6 +192,9 @@ pub(crate) async fn replace_in_html(input: &str, config: &Config) -> String { /// Set up public directories. pub(crate) async fn write_assets(config: &Config) -> PathBufD { + vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons); + + // ... let html_path = PathBufD::current().join(&config.dirs.templates); write_template!(html_path->"root.html"(crate::assets::ROOT) --config=config); @@ -194,6 +210,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config); write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config); write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config); + write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config); write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config); write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 5066906..87531fc 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -111,6 +111,7 @@ version = "1.0.0" "settings:tab.profile" = "Profile" "settings:tab.theme" = "Theme" "settings:tab.sessions" = "Sessions" +"settings:tab.connections" = "Connections" "settings:tab.images" = "Images" "settings:label.change_password" = "Change password" "settings:label.current_password" = "Current password" diff --git a/crates/app/src/public/html/auth/connection.html b/crates/app/src/public/html/auth/connection.html new file mode 100644 index 0000000..bf6ae24 --- /dev/null +++ b/crates/app/src/public/html/auth/connection.html @@ -0,0 +1,40 @@ +{% extends "auth/base.html" %} {% block head %} +Connection +{% endblock %} {% block title %}Connection{% endblock %} {% block content %} +
Working...
+ +{% if connection_type == "Spotify" and user and user.connections.Spotify and +config.connections.spotify_client_id %} + +{% endif %} {% endblock %} diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html index 5c8b1a3..125e007 100644 --- a/crates/app/src/public/html/components.html +++ b/crates/app/src/public/html/components.html @@ -801,4 +801,50 @@ secondary=false, show_community=true) -%} -{%- endmacro %} +{%- endmacro %} {% macro spotify_playing(state, size="60px") -%} {% if state and +state.data %} +
+
+
+ Listening on + {{ icon "spotify" }} +
+ + {{ state.data.timestamp }} +
+ + +
+{% endif %} {%- endmacro %} diff --git a/crates/app/src/public/html/profile/base.html b/crates/app/src/public/html/profile/base.html index b971af6..2752715 100644 --- a/crates/app/src/public/html/profile/base.html +++ b/crates/app/src/public/html/profile/base.html @@ -105,6 +105,13 @@
+ +
+ {% if profile.connections.Spotify and profile.connections.Spotify[0].data.name and profile.connections.Spotify[0].show_on_profile %} + {{ components::spotify_playing(state=profile.connections.Spotify[1]) }} + {% endif %} +
+
@@ -496,6 +501,46 @@
+ + diff --git a/crates/app/src/public/html/root.html b/crates/app/src/public/html/root.html index 145358e..e7c5b46 100644 --- a/crates/app/src/public/html/root.html +++ b/crates/app/src/public/html/root.html @@ -9,7 +9,7 @@ macros -%} @@ -111,6 +111,7 @@ macros -%} atto["hooks::check_reactions"](); atto["hooks::tabs"](); atto["hooks::partial_embeds"](); + atto["hooks::spotify_time_text"](); // spotify durations if (document.getElementById("tokens")) { trigger("me::render_token_picker", [ @@ -325,6 +326,61 @@ macros -%} trigger("atto::use_theme_preference"); }, 150); + {% endif %} {% if user and user.connections.Spotify and + config.connections.spotify_client_id and + user.connections.Spotify[0].data.token and + user.connections.Spotify[0].data.refresh_token %} + {% endif %} diff --git a/crates/app/src/public/images/vendor/spotify.svg b/crates/app/src/public/images/vendor/spotify.svg new file mode 100644 index 0000000..2022c3c --- /dev/null +++ b/crates/app/src/public/images/vendor/spotify.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 2395a50..4a9b54c 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -130,7 +130,10 @@ media_theme_pref(); let pretty = $.rel_date(then); - if (screen.width < 900 && pretty !== undefined) { + if ( + (screen.width < 900 && pretty !== undefined) | + element.classList.contains("short") + ) { // shorten dates even more for mobile pretty = pretty .replaceAll(" minutes ago", "m") @@ -381,6 +384,45 @@ media_theme_pref(); } }); + self.define("hooks::spotify_time_text", (_) => { + for (const element of Array.from( + document.querySelectorAll("[hook=spotify_time_text]") || [], + )) { + function render() { + const updated = element.getAttribute("hook-arg:updated"); + const progress = element.getAttribute("hook-arg:progress"); + const duration = element.getAttribute("hook-arg:duration"); + const display = + element.getAttribute("hook-arg:display") || "full"; + + element.innerHTML = trigger("spotify::timestamp", [ + updated, + progress, + duration, + display, + ]); + } + + setInterval(() => { + element.setAttribute( + "hook-arg:updated", + Number.parseInt(element.getAttribute("hook-arg:updated")) + + 1000, + ); + + element.setAttribute( + "hook-arg:progress", + Number.parseInt(element.getAttribute("hook-arg:progress")) + + 1000, + ); + + render(); + }, 1000); + + render(); + } + }); + self.define("last_seen_just_now", (_, last_seen) => { const now = new Date().getTime(); const maximum_time_to_be_considered_online = 60000 * 2; // 2 minutes @@ -999,7 +1041,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} // open page if (warning_page !== "") { - window.location.href = warning_page; + Turbo.visit(warning_page); return; } }, diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 0dd48fe..9348fe7 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -279,3 +279,298 @@ document.getElementById("tokens_dialog").showModal(); }); })(); + +(() => { + const self = reg_ns("connections"); + + self.define("pkce_verifier", async (_, length) => { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (let i = 0; i < length; i++) { + text += possible.charAt( + Math.floor(Math.random() * possible.length), + ); + } + + return text; + }); + + self.define("pkce_challenge", async (_, verifier) => { + const data = new TextEncoder().encode(verifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + return btoa( + String.fromCharCode.apply(null, [...new Uint8Array(digest)]), + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + }); + + self.define("delete", async (_, connection) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + fetch(`/api/v1/auth/user/connections/${connection}`, { + method: "DELETE", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }); + + self.define("push_con_data", async (_, connection, data) => { + return await ( + await fetch("/api/v1/auth/user/connections/_data", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connection, + data, + }), + }) + ).json(); + }); + + self.define("push_con_state", async (_, connection, data) => { + return await ( + await fetch("/api/v1/auth/user/connections/_state", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connection, + data, + }), + }) + ).json(); + }); + + self.define("push_con_shown", async (_, connection, shown) => { + return await ( + await fetch("/api/v1/auth/user/connections/_shown", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connection, + shown, + }), + }) + ).json(); + }); +})(); + +(() => { + const self = reg_ns("spotify"); + + self.define("create_connection", (_, client_id) => { + fetch("/api/v1/auth/user/connections/spotify", { + method: "POST", + }) + .then((res) => res.json()) + .then(async (res) => { + // create challenge and store + const verifier = await trigger("connections::pkce_verifier", [ + 128, + ]); + + const challenge = await trigger("connections::pkce_challenge", [ + verifier, + ]); + + await trigger("connections::push_con_data", [ + "Spotify", + { + verifier, + challenge, + }, + ]); + + // ... + const params = new URLSearchParams(); + params.append("client_id", client_id); + params.append("response_type", "code"); + params.append( + "redirect_uri", + `${window.location.origin}/auth/connections_link/Spotify`, + ); + params.append( + "scope", + "user-read-recently-played user-modify-playback-state user-read-playback-state user-read-email", + ); + params.append("code_challenge_method", "S256"); + params.append("code_challenge", challenge); + + window.open( + `https://accounts.spotify.com/authorize?${params.toString()}`, + ); + + window.location.reload(); + }); + }); + + self.define("get_token", async (_, client_id, verifier, code) => { + const params = new URLSearchParams(); + params.append("client_id", client_id); + params.append("grant_type", "authorization_code"); + params.append("code", code); + params.append( + "redirect_uri", + `${window.location.origin}/auth/connections_link/Spotify`, + ); + params.append("code_verifier", verifier); + + const result = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params, + }); + + const { access_token, refresh_token, expires_in } = await result.json(); + return [access_token, refresh_token, expires_in]; + }); + + self.define("refresh_token", async (_, client_id, token) => { + const params = new URLSearchParams(); + params.append("client_id", client_id); + params.append("grant_type", "refresh_token"); + params.append("refresh_token", token); + + const result = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params, + }); + + const { access_token, refresh_token, expires_in } = await result.json(); + return [access_token, refresh_token, expires_in]; + }); + + self.define("profile", async (_, token) => { + return await ( + await fetch("https://api.spotify.com/v1/me", { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }) + ).json(); + }); + + self.define("get_playing", async (_, token) => { + // + return await ( + await fetch("https://api.spotify.com/v1/me/player", { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }) + ).json(); + }); + + self.define("publish_playing", async (_, playing) => { + if (!playing.is_playing) { + return await trigger("connections::push_con_state", [ + "Spotify", + { + external_urls: {}, + data: {}, + }, + ]); + } + + if (playing.item.is_local) { + return; + } + + if ( + window.localStorage.getItem("atto:connections.spotify/id") === + playing.item.id + ) { + // item already pushed to connection, no need right now + return; + } + + window.localStorage.setItem( + "atto:connections.spotify/id", + playing.item.id, + ); + + return await trigger("connections::push_con_state", [ + "Spotify", + { + external_urls: { + track: playing.item.external_urls.spotify, + artist: playing.item.artists[0].external_urls.spotify, + album: playing.item.album.external_urls.spotify, + album_img: playing.item.album.images[0].url, + }, + data: { + id: playing.item.id, + // track + track: playing.item.name, + artist: playing.item.artists[0].name, + album: playing.item.album.name, + // image + // img_w: playing.item.album.images[0].width.toString(), + // img_h: playing.item.album.images[0].width.toString(), + // times + timestamp: playing.timestamp.toString(), + progress_ms: playing.progress_ms.toString(), + duration_ms: playing.item.duration_ms.toString(), + }, + }, + ]); + }); + + self.define("ms_time_text", (_, ms) => { + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}:${(seconds < 10 ? "0" : "") + seconds}`; + }); + + self.define( + "timestamp", + ({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => { + const now = new Date().getTime(); + const updated = Number.parseInt(updated_) + 8000; + const elapsed_since_update = now - updated; + + const progress_ms = + Number.parseInt(progress_ms_) + elapsed_since_update; + + const duration_ms = Number.parseInt(duration_ms_); + + if (progress_ms > duration_ms) { + // song is over + return ""; + } + + if (display === "full") { + return `${$.ms_time_text(progress_ms)}/${$.ms_time_text(duration_ms)} (${Math.floor((progress_ms / duration_ms) * 100)}%)`; + } + + if (display === "left") { + return $.ms_time_text(progress_ms); + } + + return $.ms_time_text(duration_ms); + }, + ); +})(); diff --git a/crates/app/src/routes/api/v1/auth/connections/mod.rs b/crates/app/src/routes/api/v1/auth/connections/mod.rs new file mode 100644 index 0000000..3e8a2b8 --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/connections/mod.rs @@ -0,0 +1,156 @@ +pub mod spotify; + +use std::collections::HashMap; + +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use serde::Deserialize; +use tetratto_core::model::{ + auth::{ConnectionService, ExternalConnectionData}, + ApiReturn, Error, +}; +use crate::{get_user_from_token, State}; + +#[derive(Deserialize)] +pub struct UpdateConnectionInfo { + pub connection: ConnectionService, + pub data: HashMap, +} + +#[derive(Deserialize)] +pub struct UpdateConnectionState { + pub connection: ConnectionService, + pub data: ExternalConnectionData, +} + +#[derive(Deserialize)] +pub struct UpdateConnectionShownOnProfile { + pub connection: ConnectionService, + pub shown: bool, +} + +pub async fn update_info_data_request( + jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut con = match user.connections.get(&props.connection) { + Some(c) => c.to_owned(), + None => return Json(Error::NotAllowed.into()), + }; + + con.0.data = props.data; + user.connections.insert(props.connection, con); + + if let Err(e) = data + .update_user_connections(user.id, user.connections) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Updated connection".to_string(), + payload: (), + }) +} + +pub async fn update_state_request( + jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut con = match user.connections.get(&props.connection) { + Some(c) => c.to_owned(), + None => return Json(Error::NotAllowed.into()), + }; + + con.1 = props.data; + user.connections.insert(props.connection, con); + + if let Err(e) = data + .update_user_connections(user.id, user.connections) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Updated connection".to_string(), + payload: (), + }) +} + +pub async fn update_shown_on_profile_request( + jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut con = match user.connections.get(&props.connection) { + Some(c) => c.to_owned(), + None => return Json(Error::NotAllowed.into()), + }; + + con.0.show_on_profile = props.shown; + user.connections.insert(props.connection, con); + + if let Err(e) = data + .update_user_connections(user.id, user.connections) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Updated connection".to_string(), + payload: (), + }) +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(service): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + user.connections.remove(&service); + + if let Err(e) = data + .update_user_connections(user.id, user.connections) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Connection removed".to_string(), + payload: (), + }) +} diff --git a/crates/app/src/routes/api/v1/auth/connections/spotify.rs b/crates/app/src/routes/api/v1/auth/connections/spotify.rs new file mode 100644 index 0000000..8d0db30 --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/connections/spotify.rs @@ -0,0 +1,42 @@ +use axum::{response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::{ + database::connections::spotify::SpotifyConnection, + model::{ + auth::{ConnectionService, ExternalConnectionData}, + ApiReturn, Error, + }, +}; +use crate::{get_user_from_token, State}; + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let con = ( + SpotifyConnection::connection(), + ExternalConnectionData::default(), + ); + + user.connections + .insert(ConnectionService::Spotify, con.clone()); + + if let Err(e) = data + .update_user_connections(user.id, user.connections) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Connection created".to_string(), + payload: Some(con.0.data), + }) +} diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index 95a2e9a..3d2067e 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -1,3 +1,4 @@ +pub mod connections; pub mod images; pub mod ipbans; pub mod profile; diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 5265ad4..632f2cb 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -59,6 +59,20 @@ pub async fn redirect_from_ip( } } +pub async fn me_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + return Json(ApiReturn { + ok: true, + message: "User exists".to_string(), + payload: Some(user), + }); +} + /// Update the settings of the given user. pub async fn update_user_settings_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index c0135f4..bd92bcb 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -124,6 +124,7 @@ pub fn routes() -> Router { post(auth::images::upload_banner_request), ) // profile + .route("/auth/user/me", get(auth::profile::me_request)) .route("/auth/user/{id}/avatar", get(auth::images::avatar_request)) .route("/auth/user/{id}/banner", get(auth::images::banner_request)) .route("/auth/user/{id}/follow", post(auth::social::follow_request)) @@ -232,6 +233,27 @@ pub fn routes() -> Router { delete(requests::delete_request), ) .route("/requests/my", delete(requests::delete_all_request)) + // connections + .route( + "/auth/user/connections/_data", + post(auth::connections::update_info_data_request), + ) + .route( + "/auth/user/connections/_state", + post(auth::connections::update_state_request), + ) + .route( + "/auth/user/connections/_shown", + post(auth::connections::update_shown_on_profile_request), + ) + .route( + "/auth/user/connections/{connection}", + delete(auth::connections::delete_request), + ) + .route( + "/auth/user/connections/spotify", + post(auth::connections::spotify::create_request), + ) } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/auth.rs b/crates/app/src/routes/pages/auth.rs index adf1253..868d8a0 100644 --- a/crates/app/src/routes/pages/auth.rs +++ b/crates/app/src/routes/pages/auth.rs @@ -1,9 +1,13 @@ use crate::{State, assets::initial_context, get_lang, get_user_from_token}; use axum::{ - Extension, + extract::Path, response::{Html, IntoResponse}, + Extension, }; use axum_extra::extract::CookieJar; +use tetratto_core::model::{Error, auth::ConnectionService}; + +use super::render_error; /// `/auth/login` pub async fn login_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { @@ -37,3 +41,22 @@ pub async fn register_request( Html(data.1.render("auth/register.html", &context).unwrap()) } + +/// `/auth/connections_link/{service}` +pub async fn connection_callback_request( + jar: CookieJar, + Extension(data): Extension, + Path(service): Path, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Html(render_error(Error::NotAllowed, &jar, &data, &None).await), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + + context.insert("connection_type", &service); + Html(data.1.render("auth/connection.html", &context).unwrap()) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index dc736d1..7da4969 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -53,6 +53,10 @@ pub fn routes() -> Router { // auth .route("/auth/register", get(auth::register_request)) .route("/auth/login", get(auth::login_request)) + .route( + "/auth/connections_link/{service}", + get(auth::connection_callback_request), + ) // profile .route("/settings", get(profile::settings_request)) .route("/@{username}", get(profile::posts_request)) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 5031fda..2da9075 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "1.0.7" +version = "1.0.8" edition = "2024" [features] @@ -17,6 +17,7 @@ tetratto-shared = { path = "../shared" } tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } +reqwest = { version = "0.12.15", features = ["json"] } redis = { version = "0.30.0", optional = true } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 1fe4ea6..a74d24f 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -150,6 +150,21 @@ impl Default for TurnstileConfig { } } +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct ConnectionsConfig { + /// + #[serde(default)] + pub spotify_client_id: Option, +} + +impl Default for ConnectionsConfig { + fn default() -> Self { + Self { + spotify_client_id: None, + } + } +} + /// Configuration file #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Config { @@ -205,6 +220,8 @@ pub struct Config { /// This community **must** have open write access. #[serde(default)] pub town_square: usize, + #[serde(default)] + pub connections: ConnectionsConfig, } fn default_name() -> String { @@ -269,6 +286,10 @@ fn default_turnstile() -> TurnstileConfig { TurnstileConfig::default() } +fn default_connections() -> ConnectionsConfig { + ConnectionsConfig::default() +} + impl Default for Config { fn default() -> Self { Self { @@ -286,6 +307,7 @@ impl Default for Config { policies: default_policies(), turnstile: default_turnstile(), town_square: 0, + connections: default_connections(), } } } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 188974a..102a04c 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,5 +1,6 @@ use super::*; use crate::cache::Cache; +use crate::model::auth::UserConnections; use crate::model::moderation::AuditLogEntry; use crate::model::{ Error, Result, @@ -42,6 +43,7 @@ impl DataManager { recovery_codes: serde_json::from_str(&get!(x->14(String)).to_string()).unwrap(), post_count: get!(x->15(i32)) as usize, request_count: get!(x->16(i32)) as usize, + connections: serde_json::from_str(&get!(x->17(String)).to_string()).unwrap(), } } @@ -136,7 +138,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)", params![ &(data.id as i64), &(data.created as i64), @@ -154,7 +156,8 @@ impl DataManager { &String::new(), &"[]", &0_i32, - &0_i32 + &0_i32, + &serde_json::to_string(&data.connections).unwrap(), ] ); @@ -630,6 +633,7 @@ impl DataManager { auto_method!(update_user_tokens(Vec)@get_user_by_id -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(incr_user_notifications()@get_user_by_id -> "UPDATE users SET notification_count = notification_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr); auto_method!(decr_user_notifications()@get_user_by_id -> "UPDATE users SET notification_count = notification_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr); diff --git a/crates/core/src/database/connections/mod.rs b/crates/core/src/database/connections/mod.rs new file mode 100644 index 0000000..16b75bc --- /dev/null +++ b/crates/core/src/database/connections/mod.rs @@ -0,0 +1 @@ +pub mod spotify; diff --git a/crates/core/src/database/connections/spotify.rs b/crates/core/src/database/connections/spotify.rs new file mode 100644 index 0000000..41aa10c --- /dev/null +++ b/crates/core/src/database/connections/spotify.rs @@ -0,0 +1,21 @@ +use std::collections::HashMap; +use crate::{ + config::Config, + model::auth::{ConnectionType, ExternalConnectionInfo, UserConnections}, +}; + +/// A connection to Spotify. +/// +/// +pub struct SpotifyConnection(pub UserConnections, pub Config); + +impl SpotifyConnection { + /// Create a new [`ExternalConnectionInfo`] for the connection. + pub fn connection() -> ExternalConnectionInfo { + ExternalConnectionInfo { + con_type: ConnectionType::PKCE, + data: HashMap::new(), + show_on_profile: true, + } + } +} diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 92dda7e..767fe73 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -15,5 +15,6 @@ CREATE TABLE IF NOT EXISTS users ( totp TEXT NOT NULL, recovery_codes TEXT NOT NULL, post_count INT NOT NULL, - request_count INT NOT NULL + request_count INT NOT NULL, + connections TEXT NOT NULL ) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index a1d16b5..a094500 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -2,6 +2,7 @@ mod audit_log; mod auth; mod common; mod communities; +pub mod connections; mod drivers; mod ipbans; mod ipblocks; @@ -17,9 +18,9 @@ mod userblocks; mod userfollows; #[cfg(feature = "redis")] -pub mod channels; +mod channels; #[cfg(feature = "redis")] -pub mod messages; +mod messages; #[cfg(feature = "sqlite")] pub use drivers::sqlite::*; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 6c194fe..855a094 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::permissions::FinePermission; use serde::{Deserialize, Serialize}; use totp_rs::TOTP; @@ -35,8 +37,14 @@ pub struct User { pub post_count: usize, #[serde(default)] pub request_count: usize, + /// External service connection details. + #[serde(default)] + pub connections: UserConnections, } +pub type UserConnections = + HashMap; + #[derive(Clone, Debug, Serialize, Deserialize)] pub enum ThemePreference { Auto, @@ -220,6 +228,7 @@ impl User { recovery_codes: Vec::new(), post_count: 0, request_count: 0, + connections: HashMap::new(), } } @@ -333,6 +342,42 @@ impl User { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ConnectionService { + /// A connection to a Spotify account. + Spotify, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ConnectionType { + /// A connection through a token with an expiration time. + Token, + /// + PKCE, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExternalConnectionInfo { + pub con_type: ConnectionType, + pub data: HashMap, + pub show_on_profile: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExternalConnectionData { + pub external_urls: HashMap, + pub data: HashMap, +} + +impl Default for ExternalConnectionData { + fn default() -> Self { + Self { + external_urls: HashMap::new(), + data: HashMap::new(), + } + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct Notification { pub id: usize, diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index c3bf434..31bde09 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "1.0.7" +version = "1.0.8" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 85c3fda..2145340 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "1.0.7" +version = "1.0.8" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/example/tetratto.toml b/example/tetratto.toml index 27c651f..084e340 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -15,7 +15,7 @@ banned_usernames = [ "notification", "post", "void", - "anonymous" + "anonymous", ] town_square = 166340372315581657 diff --git a/sql_changes/users_connections.sql b/sql_changes/users_connections.sql new file mode 100644 index 0000000..7489f73 --- /dev/null +++ b/sql_changes/users_connections.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN connections TEXT NOT NULL DEFAULT '{}';