add: profile connections, spotify connection

This commit is contained in:
trisua 2025-04-26 16:27:18 -04:00
parent a5c2356940
commit 33ba576d4a
31 changed files with 931 additions and 19 deletions

9
Cargo.lock generated
View file

@ -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",

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "1.0.7"
version = "1.0.8"
edition = "2024"
[features]

View file

@ -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<RwLock<HashMap<String, String>>> =
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);

View file

@ -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"

View file

@ -0,0 +1,40 @@
{% extends "auth/base.html" %} {% block head %}
<title>Connection</title>
{% endblock %} {% block title %}Connection{% endblock %} {% block content %}
<div class="w-full flex-col gap-2" id="status"><b>Working...</b></div>
{% if connection_type == "Spotify" and user and user.connections.Spotify and
config.connections.spotify_client_id %}
<script>
setTimeout(async () => {
const code = new URLSearchParams(window.location.search).get("code");
const client_id = "{{ config.connections.spotify_client_id }}";
const verifier = "{{ user.connections.Spotify[0].data.verifier }}";
if (!code) {
alert("Connection failed (did not get code from Spotify)");
return;
}
const [token, refresh_token, expires_in] = await trigger(
"spotify::get_token",
[client_id, verifier, code],
);
const profile = await trigger("spotify::profile", [token]);
const { message } = await trigger("connections::push_con_data", [
"Spotify",
{
token,
refresh_token,
expires_in: expires_in.toString(),
name: profile.display_name,
},
]);
document.getElementById("status").innerHTML =
`<b>${message}.</b> You can now close this tab.`;
}, 150);
</script>
{% endif %} {% endblock %}

View file

@ -801,4 +801,50 @@ secondary=false, show_community=true) -%}
</div>
</div>
</div>
{%- endmacro %}
{%- endmacro %} {% macro spotify_playing(state, size="60px") -%} {% if state and
state.data %}
<div class="card-nest">
<div class="card flex items-center justify-between gap-2 small">
<div class="flex items-center gap-2">
<b>Listening on</b>
{{ icon "spotify" }}
</div>
<span class="fade date short">{{ state.data.timestamp }}</span>
</div>
<div class="card secondary flex gap-2">
<a href="{{ state.external_urls.album }}">
<img
src="{{ state.external_urls.album_img }}"
alt="Album cover"
loading="lazy"
class="avatar"
style="--size: {{ size }}"
/>
</a>
<div class="flex flex-col">
<h5 class="w-full">
<a href="{{ state.external_urls.track }}" class="flush"
>{{ state.data.track }}</a
>
</h5>
<span class="fade"
><a href="{{ state.external_urls.artist }}" class="flush"
>{{ state.data.artist }}</a
></span
>
<span
hook="spotify_time_text"
hook-arg:updated="{{ state.data.timestamp }}"
hook-arg:progress="{{ state.data.progress_ms }}"
hook-arg:duration="{{ state.data.duration_ms }}"
hook-arg:display="full"
></span>
</div>
</div>
</div>
{% endif %} {%- endmacro %}

View file

@ -105,6 +105,13 @@
</div>
<div class="card flex flex-col gap-2">
<!-- prettier-ignore -->
<div style="display: contents;">
{% 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 %}
</div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">ID</span>
<button

View file

@ -29,6 +29,11 @@
{{ icon "cookie" }}
<span>{{ text "settings:tab.sessions" }}</span>
</a>
<a data-tab-button="connections" href="#/connections">
{{ icon "cable" }}
<span>{{ text "settings:tab.connections" }}</span>
</a>
</div>
<div class="w-full flex flex-col gap-2" data-tab="account">
@ -496,6 +501,46 @@
</button>
</div>
<div
class="card w-full tertiary hidden flex flex-col gap-2"
data-tab="connections"
>
<div class="card w-full flex flex-wrap gap-2">
{% if config.connections.spotify_client_id and not
user.connections.Spotify %}
<button
class="quaternary"
onclick="trigger('spotify::create_connection', ['{{ config.connections.spotify_client_id }}'])"
>
{{ icon "spotify" }}
<span>Spotify</span>
</button>
{% endif %}
</div>
{% for key, value in user.connections %}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{% if key == "Spotify" %} {{ icon "spotify" }} {% endif %}
<b>
{% if value[0].data.name %} {{ value[0].data.name }} {% else
%} {{ key }} {% endif %}
</b>
</div>
<div class="card flex items-center gap-2">
<button
class="quaternary red small"
onclick="trigger('connections::delete', ['{{ key }}'])"
>
{{ text "general:action.delete" }}
</button>
</div>
</div>
{% endfor %}
</div>
<!-- prettier-ignore -->
<script type="application/json" id="settings_json">{{ user_settings_serde|safe }}</script>

View file

@ -9,7 +9,7 @@ macros -%}
<meta
http-equiv="content-security-policy"
content="default-src 'self' blob:; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"
content="default-src 'self' blob: *.spotify.com; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"
/>
<link rel="icon" href="/public/favicon.svg" />
@ -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);
</script>
{% 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 %}
<script>
setTimeout(async () => {
if (window.spotify_init) {
return;
}
window.spotify_init = true;
const client_id = "{{ config.connections.spotify_client_id }}";
let token = "{{ user.connections.Spotify[0].data.token }}";
let refresh_token =
"{{ user.connections.Spotify[0].data.refresh_token }}";
if (token) {
// we already have a token
const pull_playing = async () => {
const playing = await trigger("spotify::get_playing", [
token,
]);
if (playing.error) {
// refresh token
const [new_token, new_refresh_token, expires_in] =
await trigger("spotify::refresh_token", [
client_id,
refresh_token,
]);
await trigger("connections::push_con_data", [
"Spotify",
{
token: new_token,
expires_in: expires_in.toString(),
name: profile.display_name,
},
]);
token = new_token;
refresh_token = new_refresh_token;
return;
}
await trigger("spotify::publish_playing", [playing]);
};
await pull_playing();
setInterval(pull_playing, 30_000);
} else {
window.spotify_needs_token = true;
}
}, 150);
</script>
{% endif %}
</body>
</html>

View file

@ -0,0 +1,2 @@
<!-- https://developer.spotify.com/documentation/design#using-our-logo -->
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 236.05 225.25"><defs><style>.cls-1{fill:#1ed760;stroke-width:0px;}</style></defs><path class="cls-1" d="m122.37,3.31C61.99.91,11.1,47.91,8.71,108.29c-2.4,60.38,44.61,111.26,104.98,113.66,60.38,2.4,111.26-44.6,113.66-104.98C229.74,56.59,182.74,5.7,122.37,3.31Zm46.18,160.28c-1.36,2.4-4.01,3.6-6.59,3.24-.79-.11-1.58-.37-2.32-.79-14.46-8.23-30.22-13.59-46.84-15.93-16.62-2.34-33.25-1.53-49.42,2.4-3.51.85-7.04-1.3-7.89-4.81-.85-3.51,1.3-7.04,4.81-7.89,17.78-4.32,36.06-5.21,54.32-2.64,18.26,2.57,35.58,8.46,51.49,17.51,3.13,1.79,4.23,5.77,2.45,8.91Zm14.38-28.72c-2.23,4.12-7.39,5.66-11.51,3.43-16.92-9.15-35.24-15.16-54.45-17.86-19.21-2.7-38.47-1.97-57.26,2.16-1.02.22-2.03.26-3.01.12-3.41-.48-6.33-3.02-7.11-6.59-1.01-4.58,1.89-9.11,6.47-10.12,20.77-4.57,42.06-5.38,63.28-2.4,21.21,2.98,41.46,9.62,60.16,19.74,4.13,2.23,5.66,7.38,3.43,11.51Zm15.94-32.38c-2.1,4.04-6.47,6.13-10.73,5.53-1.15-.16-2.28-.52-3.37-1.08-19.7-10.25-40.92-17.02-63.07-20.13-22.15-3.11-44.42-2.45-66.18,1.97-5.66,1.15-11.17-2.51-12.32-8.16-1.15-5.66,2.51-11.17,8.16-12.32,24.1-4.89,48.74-5.62,73.25-2.18,24.51,3.44,47.99,10.94,69.81,22.29,5.12,2.66,7.11,8.97,4.45,14.09Z"/></svg>

View file

@ -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}</textarea>` : ""}
// open page
if (warning_page !== "") {
window.location.href = warning_page;
Turbo.visit(warning_page);
return;
}
},

View file

@ -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) => {
// <https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback>
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)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
}
if (display === "left") {
return $.ms_time_text(progress_ms);
}
return $.ms_time_text(duration_ms);
},
);
})();

View file

@ -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<String, String>,
}
#[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<State>,
Json(props): Json<UpdateConnectionInfo>,
) -> 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<State>,
Json(props): Json<UpdateConnectionState>,
) -> 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<State>,
Json(props): Json<UpdateConnectionShownOnProfile>,
) -> 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<State>,
Path(service): Path<ConnectionService>,
) -> 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: (),
})
}

View file

@ -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<State>,
) -> 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),
})
}

View file

@ -1,3 +1,4 @@
pub mod connections;
pub mod images;
pub mod ipbans;
pub mod profile;

View file

@ -59,6 +59,20 @@ pub async fn redirect_from_ip(
}
}
pub async fn me_request(jar: CookieJar, Extension(data): Extension<State>) -> 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,

View file

@ -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)]

View file

@ -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<State>) -> 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<State>,
Path(service): Path<ConnectionService>,
) -> 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())
}

View file

@ -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))

View file

@ -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 }

View file

@ -150,6 +150,21 @@ impl Default for TurnstileConfig {
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct ConnectionsConfig {
/// <https://developer.spotify.com/documentation/web-api>
#[serde(default)]
pub spotify_client_id: Option<String>,
}
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(),
}
}
}

View file

@ -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<Token>)@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);

View file

@ -0,0 +1 @@
pub mod spotify;

View file

@ -0,0 +1,21 @@
use std::collections::HashMap;
use crate::{
config::Config,
model::auth::{ConnectionType, ExternalConnectionInfo, UserConnections},
};
/// A connection to Spotify.
///
/// <https://developer.spotify.com/documentation/web-api>
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,
}
}
}

View file

@ -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
)

View file

@ -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::*;

View file

@ -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<ConnectionService, (ExternalConnectionInfo, ExternalConnectionData)>;
#[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,
/// <https://www.rfc-editor.org/rfc/rfc7636>
PKCE,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExternalConnectionInfo {
pub con_type: ConnectionType,
pub data: HashMap<String, String>,
pub show_on_profile: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExternalConnectionData {
pub external_urls: HashMap<String, String>,
pub data: HashMap<String, String>,
}
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,

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-l10n"
version = "1.0.7"
version = "1.0.8"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-shared"
version = "1.0.7"
version = "1.0.8"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -15,7 +15,7 @@ banned_usernames = [
"notification",
"post",
"void",
"anonymous"
"anonymous",
]
town_square = 166340372315581657

View file

@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN connections TEXT NOT NULL DEFAULT '{}';