add: profile connections, spotify connection
This commit is contained in:
parent
a5c2356940
commit
33ba576d4a
31 changed files with 931 additions and 19 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
|
|
40
crates/app/src/public/html/auth/connection.html
Normal file
40
crates/app/src/public/html/auth/connection.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
2
crates/app/src/public/images/vendor/spotify.svg
vendored
Normal file
2
crates/app/src/public/images/vendor/spotify.svg
vendored
Normal 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>
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
})();
|
||||
|
|
156
crates/app/src/routes/api/v1/auth/connections/mod.rs
Normal file
156
crates/app/src/routes/api/v1/auth/connections/mod.rs
Normal 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: (),
|
||||
})
|
||||
}
|
42
crates/app/src/routes/api/v1/auth/connections/spotify.rs
Normal file
42
crates/app/src/routes/api/v1/auth/connections/spotify.rs
Normal 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),
|
||||
})
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
pub mod connections;
|
||||
pub mod images;
|
||||
pub mod ipbans;
|
||||
pub mod profile;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
1
crates/core/src/database/connections/mod.rs
Normal file
1
crates/core/src/database/connections/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod spotify;
|
21
crates/core/src/database/connections/spotify.rs
Normal file
21
crates/core/src/database/connections/spotify.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-l10n"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-shared"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -15,7 +15,7 @@ banned_usernames = [
|
|||
"notification",
|
||||
"post",
|
||||
"void",
|
||||
"anonymous"
|
||||
"anonymous",
|
||||
]
|
||||
town_square = 166340372315581657
|
||||
|
||||
|
|
2
sql_changes/users_connections.sql
Normal file
2
sql_changes/users_connections.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE users
|
||||
ADD COLUMN connections TEXT NOT NULL DEFAULT '{}';
|
Loading…
Add table
Add a link
Reference in a new issue