forked from didirus/AstralRinth
Merge commit 'd51a1c47c70d44bfcc1af6fe58f244170513470c' into feature-clean
This commit is contained in:
4
.github/workflows/theseus-release.yml
vendored
4
.github/workflows/theseus-release.yml
vendored
@@ -7,9 +7,11 @@ on:
|
||||
- 'build*'
|
||||
- 'v*'
|
||||
paths:
|
||||
- .github/workflows/app-release.yml
|
||||
- .github/workflows/theseus-release.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'apps/labrinth/src/common/**'
|
||||
- 'apps/labrinth/Cargo.toml'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
|
||||
4
.idea/code.iml
generated
4
.idea/code.iml
generated
@@ -10,9 +10,11 @@
|
||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
</module>
|
||||
117
Cargo.lock
generated
117
Cargo.lock
generated
@@ -265,9 +265,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "actix-web-prom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76743e67d4e7efa9fc2ac7123de0dd7b2ca592668e19334f1d81a3b077afc6ac"
|
||||
checksum = "56a34f1825c3ae06567a9d632466809bbf34963c86002e8921b64f32d48d289d"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"futures-core",
|
||||
@@ -420,6 +420,21 @@ dependencies = [
|
||||
"password-hash 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ariadne"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"either",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_cbor",
|
||||
"serde_json",
|
||||
"thiserror 1.0.64",
|
||||
"uuid 1.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -1216,7 +1231,11 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1303,7 +1322,11 @@ dependencies = [
|
||||
"time",
|
||||
"tokio 1.43.0",
|
||||
"url",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2007,7 +2030,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2537,7 +2564,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0"
|
||||
dependencies = [
|
||||
"bit_field",
|
||||
<<<<<<< HEAD
|
||||
"half",
|
||||
=======
|
||||
"flume",
|
||||
"half 2.4.1",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
"lebe",
|
||||
"miniz_oxide",
|
||||
"rayon-core",
|
||||
@@ -3286,6 +3318,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403"
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.4.1"
|
||||
@@ -4297,6 +4335,7 @@ dependencies = [
|
||||
"actix-web-prom",
|
||||
"actix-ws",
|
||||
"argon2",
|
||||
"ariadne",
|
||||
"async-stripe",
|
||||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
@@ -4310,6 +4349,7 @@ dependencies = [
|
||||
"deadpool-redis",
|
||||
"derive-new",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"env_logger",
|
||||
"flate2",
|
||||
"futures 0.3.31",
|
||||
@@ -4342,6 +4382,8 @@ dependencies = [
|
||||
"sentry",
|
||||
"sentry-actix",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_cbor",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"sha1 0.6.1",
|
||||
@@ -4355,7 +4397,11 @@ dependencies = [
|
||||
"totp-rs",
|
||||
"url",
|
||||
"urlencoding",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
"validator",
|
||||
"webp",
|
||||
"woothee",
|
||||
@@ -4732,7 +4778,11 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"yaup",
|
||||
@@ -6862,7 +6912,11 @@ dependencies = [
|
||||
"rkyv_derive",
|
||||
"seahash",
|
||||
"tinyvec",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7234,7 +7288,11 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7500,7 +7558,11 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"url",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7535,6 +7597,25 @@ dependencies = [
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_cbor"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
|
||||
dependencies = [
|
||||
"half 1.8.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.217"
|
||||
@@ -8650,7 +8731,11 @@ dependencies = [
|
||||
"thiserror 2.0.11",
|
||||
"time",
|
||||
"url",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -8743,7 +8828,11 @@ dependencies = [
|
||||
"thiserror 2.0.11",
|
||||
"toml 0.8.20",
|
||||
"url",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8925,7 +9014,11 @@ dependencies = [
|
||||
"toml 0.8.20",
|
||||
"url",
|
||||
"urlpattern",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -8988,6 +9081,7 @@ dependencies = [
|
||||
name = "theseus"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"async-recursion",
|
||||
"async-tungstenite",
|
||||
"async_zip",
|
||||
@@ -9000,6 +9094,7 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"discord-rich-presence",
|
||||
"dunce",
|
||||
"either",
|
||||
"flate2",
|
||||
"futures 0.3.31",
|
||||
"indicatif",
|
||||
@@ -9029,7 +9124,11 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
"whoami",
|
||||
"winreg 0.52.0",
|
||||
"zip 0.6.6",
|
||||
@@ -9071,7 +9170,11 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-error",
|
||||
"url",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
"window-shadows",
|
||||
]
|
||||
|
||||
@@ -9090,7 +9193,11 @@ dependencies = [
|
||||
"tracing-error",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
<<<<<<< HEAD
|
||||
"uuid 1.13.1",
|
||||
=======
|
||||
"uuid 1.12.0",
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
"webbrowser",
|
||||
]
|
||||
|
||||
@@ -9952,9 +10059,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
<<<<<<< HEAD
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0"
|
||||
=======
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4"
|
||||
>>>>>>> d51a1c47c70d44bfcc1af6fe58f244170513470c
|
||||
dependencies = [
|
||||
"getrandom 0.3.1",
|
||||
"js-sys",
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
'./apps/labrinth',
|
||||
'./apps/daedalus_client',
|
||||
'./packages/daedalus',
|
||||
'./packages/ariadne',
|
||||
]
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
@@ -21,4 +22,4 @@ strip = true # Remove debug symbols
|
||||
opt-level = 3
|
||||
|
||||
[patch.crates-io]
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
|
||||
|
||||
@@ -39,10 +39,10 @@ defineExpose({
|
||||
})
|
||||
|
||||
function onModalHide() {
|
||||
// if (props.showAdOnClose) {
|
||||
// show_ads_window()
|
||||
// }
|
||||
props.onHide()
|
||||
// if (props.showAdOnClose) {
|
||||
// show_ads_window()
|
||||
// }
|
||||
props.onHide?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
2
apps/app-playground/.cargo/config.toml
Normal file
2
apps/app-playground/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
SQLX_OFFLINE = "true"
|
||||
@@ -3,9 +3,9 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use std::time::Duration;
|
||||
use theseus::prelude::*;
|
||||
|
||||
use theseus::profile::create::profile_create;
|
||||
use tokio::signal::ctrl_c;
|
||||
|
||||
// A simple Rust implementation of the authentication run
|
||||
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
|
||||
@@ -41,54 +41,21 @@ async fn main() -> theseus::Result<()> {
|
||||
// Initialize state
|
||||
State::init().await?;
|
||||
|
||||
if minecraft_auth::users().await?.is_empty() {
|
||||
println!("No users found, authenticating.");
|
||||
authenticate_run().await?; // could take credentials from here direct, but also deposited in state users
|
||||
}
|
||||
//
|
||||
// st.settings
|
||||
// .write()
|
||||
// .await
|
||||
// .java_globals
|
||||
// .insert(JAVA_8_KEY.to_string(), check_jre(path).await?.unwrap());
|
||||
// Clear profiles
|
||||
println!("Clearing profiles.");
|
||||
{
|
||||
let h = profile::list().await?;
|
||||
for profile in h.into_iter() {
|
||||
profile::remove(&profile.path).await?;
|
||||
loop {
|
||||
if State::get().await?.friends_socket.is_connected().await {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
println!("Creating/adding profile.");
|
||||
tracing::info!("Starting host");
|
||||
|
||||
let name = "Example".to_string();
|
||||
let game_version = "1.16.1".to_string();
|
||||
let modloader = ModLoader::Forge;
|
||||
let loader_version = "stable".to_string();
|
||||
let socket = State::get().await?.friends_socket.open_port(25565).await?;
|
||||
tracing::info!("Running host on socket {}", socket.socket_id());
|
||||
|
||||
let profile_path = profile_create(
|
||||
name,
|
||||
game_version,
|
||||
modloader,
|
||||
Some(loader_version),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("running");
|
||||
// Run a profile, running minecraft and store the RwLock to the process
|
||||
let process = profile::run(&profile_path).await?;
|
||||
|
||||
println!("Minecraft UUID: {}", process.uuid);
|
||||
|
||||
println!("All running process UUID {:?}", process::get_all().await?);
|
||||
|
||||
// hold the lock to the process until it ends
|
||||
println!("Waiting for process to end...");
|
||||
process::wait_for(process.uuid).await?;
|
||||
ctrl_c().await?;
|
||||
tracing::info!("Stopping host");
|
||||
socket.shutdown().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
2
apps/app/.cargo/config.toml
Normal file
2
apps/app/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
SQLX_OFFLINE = "true"
|
||||
@@ -10,12 +10,12 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.3",
|
||||
"@astrojs/starlight": "^0.26.3",
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/starlight": "^0.32.2",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"astro": "^4.10.2",
|
||||
"sharp": "^0.32.5",
|
||||
"starlight-openapi": "^0.7.0",
|
||||
"typescript": "^5.5.4"
|
||||
"astro": "^5.4.1",
|
||||
"sharp": "^0.33.5",
|
||||
"starlight-openapi": "^0.14.0",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/docs/src/content.config.ts
Normal file
7
apps/docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineCollection } from 'astro:content'
|
||||
import { docsSchema } from '@astrojs/starlight/schema'
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ schema: docsSchema() }),
|
||||
}
|
||||
@@ -140,6 +140,8 @@
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
max-width: calc(100% - 2rem);
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
|
||||
> div {
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
||||
|
||||
@@ -19,13 +19,21 @@
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div v-else-if="report.item_type === 'user'" class="item-info">
|
||||
<nuxt-link :to="`/user/${report.user.username}`" class="iconified-stacked-link">
|
||||
<nuxt-link
|
||||
v-if="report.user"
|
||||
:to="`/user/${report.user.username}`"
|
||||
class="iconified-stacked-link"
|
||||
>
|
||||
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
|
||||
<div class="stacked">
|
||||
<span class="title">{{ report.user.username }}</span>
|
||||
<span>User</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<div v-else class="item-info">
|
||||
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
|
||||
<span>Reported user not found: <CopyCode :text="report.item_id" /> </span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="report.item_type === 'version'" class="item-info">
|
||||
<nuxt-link
|
||||
@@ -50,7 +58,7 @@
|
||||
</div>
|
||||
<div v-else class="item-info">
|
||||
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
|
||||
<span>Unknown report type</span>
|
||||
<span>Unknown report type: {{ report.item_type }}</span>
|
||||
</div>
|
||||
<div class="report-type">
|
||||
<Badge v-if="report.closed" type="closed" />
|
||||
|
||||
@@ -5,6 +5,27 @@
|
||||
<div class="pointer-events-none absolute inset-0 z-[-1]">
|
||||
<div id="absolute-background-teleport" class="relative"></div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-0 z-50">
|
||||
<div
|
||||
class="over-the-top-random-animation"
|
||||
:style="{ '--_r-count': rCount }"
|
||||
:class="{ threshold: rCount > 20, 'rings-expand': rCount >= 40 }"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight text-9xl font-extrabold text-contrast"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
|
||||
<div
|
||||
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
|
||||
@@ -206,7 +227,6 @@
|
||||
<template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="
|
||||
@@ -231,14 +251,52 @@
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<ButtonStyled type="transparent">
|
||||
<OverflowMenu
|
||||
v-if="auth.user && isStaff(auth.user)"
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
:dropdown-id="`${basePopoutId}-staff`"
|
||||
aria-label="Create new..."
|
||||
:options="[
|
||||
{
|
||||
id: 'review-projects',
|
||||
color: 'orange',
|
||||
link: '/moderation/review',
|
||||
},
|
||||
{
|
||||
id: 'review-reports',
|
||||
color: 'orange',
|
||||
link: '/moderation/reports',
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'user-lookup',
|
||||
color: 'primary',
|
||||
link: '/admin/user_email',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #review-projects> <ScaleIcon aria-hidden="true" /> Review projects </template>
|
||||
<template #review-reports> <ReportIcon aria-hidden="true" /> Reports </template>
|
||||
<template #user-lookup> <UserIcon aria-hidden="true" /> Lookup by email </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<OverflowMenu
|
||||
v-if="auth.user"
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
:dropdown-id="createPopoutId"
|
||||
:dropdown-id="`${basePopoutId}-create`"
|
||||
aria-label="Create new..."
|
||||
:options="[
|
||||
{
|
||||
@@ -270,7 +328,7 @@
|
||||
</ButtonStyled>
|
||||
<OverflowMenu
|
||||
v-if="auth.user"
|
||||
:dropdown-id="userPopoutId"
|
||||
:dropdown-id="`${basePopoutId}-user`"
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||
:options="userMenuOptions"
|
||||
>
|
||||
@@ -291,7 +349,7 @@
|
||||
</template>
|
||||
<template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </template>
|
||||
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
|
||||
<template #moderation> <ModerationIcon aria-hidden="true" /> Moderation </template>
|
||||
<template #moderation> <ScaleIcon aria-hidden="true" /> Moderation </template>
|
||||
<template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
<template v-else>
|
||||
@@ -378,7 +436,7 @@
|
||||
class="iconified-button"
|
||||
to="/moderation"
|
||||
>
|
||||
<ModerationIcon aria-hidden="true" />
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.moderationLabel) }}
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags">
|
||||
@@ -439,7 +497,7 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<NotificationIcon aria-hidden="true" />
|
||||
<BellIcon aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/dashboard"
|
||||
@@ -458,7 +516,7 @@
|
||||
>
|
||||
<template v-if="!auth.user">
|
||||
<HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" />
|
||||
<CrossIcon v-else aria-hidden="true" />
|
||||
<XIcon v-else aria-hidden="true" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Avatar
|
||||
@@ -568,6 +626,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ModrinthIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
BookmarkIcon,
|
||||
ServerIcon,
|
||||
@@ -605,12 +664,11 @@ import {
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
GitHubIcon,
|
||||
XIcon as CrossIcon,
|
||||
ScaleIcon as ModerationIcon,
|
||||
BellIcon as NotificationIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
|
||||
|
||||
import { isAdmin, isStaff } from "@modrinth/utils";
|
||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
@@ -628,10 +686,10 @@ const flags = useFeatureFlags();
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const route = useNativeRoute();
|
||||
const router = useNativeRouter();
|
||||
const link = config.public.siteUrl + route.path.replace(/\/+$/, "");
|
||||
|
||||
const createPopoutId = useId();
|
||||
const userPopoutId = useId();
|
||||
const basePopoutId = useId();
|
||||
|
||||
const verifyEmailBannerMessages = defineMessages({
|
||||
title: {
|
||||
@@ -890,12 +948,57 @@ const isDiscoveringSubpage = computed(
|
||||
() => route.name && route.name.startsWith("type-id") && !route.query.sid,
|
||||
);
|
||||
|
||||
const rCount = ref(0);
|
||||
|
||||
const randomProjects = ref([]);
|
||||
const disableRandomProjects = ref(false);
|
||||
|
||||
const disableRandomProjectsForRoute = computed(
|
||||
() =>
|
||||
route.name.startsWith("servers") ||
|
||||
route.name.includes("settings") ||
|
||||
route.name.includes("admin"),
|
||||
);
|
||||
|
||||
async function onKeyDown(event) {
|
||||
if (disableRandomProjects.value || disableRandomProjectsForRoute.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "r") {
|
||||
rCount.value++;
|
||||
|
||||
if (randomProjects.value.length < 3) {
|
||||
randomProjects.value = await useBaseFetch("projects_random?count=50").catch((err) => {
|
||||
console.error(err);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rCount.value >= 40) {
|
||||
rCount.value = 0;
|
||||
const randomProject = randomProjects.value[0];
|
||||
await router.push(`/project/${randomProject.slug}`);
|
||||
randomProjects.value.splice(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp(event) {
|
||||
if (event.key === "r") {
|
||||
rCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (window && import.meta.client) {
|
||||
window.history.scrollRestoration = "auto";
|
||||
}
|
||||
|
||||
runAnalytics();
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -1482,5 +1585,115 @@ const footerLinks = [
|
||||
background: var(--brand-gradient-strong-bg);
|
||||
border-color: var(--brand-gradient-border);
|
||||
}
|
||||
|
||||
.over-the-top-random-animation {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
scale: 0.5;
|
||||
transition: all 0.5s ease-out;
|
||||
opacity: 0;
|
||||
animation:
|
||||
tilt-shaking calc(0.2s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite,
|
||||
translate-x-shaking calc(0.3s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite,
|
||||
translate-y-shaking calc(0.25s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite;
|
||||
|
||||
&.threshold {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.rings-expand {
|
||||
scale: 0.8;
|
||||
opacity: 0;
|
||||
|
||||
.animation-ring-1 {
|
||||
width: 25rem;
|
||||
height: 25rem;
|
||||
}
|
||||
.animation-ring-2 {
|
||||
width: 50rem;
|
||||
height: 50rem;
|
||||
}
|
||||
.animation-ring-3 {
|
||||
width: 100rem;
|
||||
height: 100rem;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
|
||||
> * {
|
||||
position: absolute;
|
||||
scale: calc(1 + max((var(--_r-count) - 20), 0) * 0.1);
|
||||
transition: all 0.2s ease-out;
|
||||
width: 20rem;
|
||||
height: 20rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tilt-shaking {
|
||||
0% {
|
||||
rotate: 0deg;
|
||||
}
|
||||
25% {
|
||||
rotate: calc(1deg * (var(--_r-count) - 20));
|
||||
}
|
||||
50% {
|
||||
rotate: 0deg;
|
||||
}
|
||||
75% {
|
||||
rotate: calc(-1deg * (var(--_r-count) - 20));
|
||||
}
|
||||
100% {
|
||||
rotate: 0deg;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes translate-x-shaking {
|
||||
0% {
|
||||
translate: 0;
|
||||
}
|
||||
25% {
|
||||
translate: calc(2px * (var(--_r-count) - 20));
|
||||
}
|
||||
50% {
|
||||
translate: 0;
|
||||
}
|
||||
75% {
|
||||
translate: calc(-2px * (var(--_r-count) - 20));
|
||||
}
|
||||
100% {
|
||||
translate: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes translate-y-shaking {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(calc(2px * (var(--_r-count) - 20)));
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(calc(-2px * (var(--_r-count) - 20)));
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
||||
|
||||
@@ -98,6 +98,14 @@
|
||||
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
|
||||
shown: !currentMember,
|
||||
},
|
||||
{ divider: true, shown: currentMember || flags.developerMode },
|
||||
{
|
||||
id: 'copy-id',
|
||||
action: () => {
|
||||
copyToClipboard(version.id);
|
||||
},
|
||||
shown: currentMember || flags.developerMode,
|
||||
},
|
||||
{ divider: true, shown: currentMember },
|
||||
{
|
||||
id: 'edit',
|
||||
@@ -148,6 +156,10 @@
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Delete
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy ID
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -174,6 +186,7 @@ import {
|
||||
ReportIcon,
|
||||
UploadIcon,
|
||||
InfoIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import DropArea from "~/components/ui/DropArea.vue";
|
||||
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||
|
||||
@@ -58,50 +58,137 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="normal-page no-sidebar">
|
||||
<h1>{{ user.username }}'s subscriptions</h1>
|
||||
<div class="normal-page__content">
|
||||
<div class="page experimental-styles-within">
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :src="user.avatar_url" :alt="user.username" size="32px" circle />
|
||||
<h1 class="m-0 text-2xl font-extrabold">{{ user.username }}'s subscriptions</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<nuxt-link :to="`/user/${user.id}`">
|
||||
<UserIcon aria-hidden="true" />
|
||||
User profile
|
||||
<ExternalIcon class="h-4 w-4" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
|
||||
<span class="font-extrabold text-contrast">
|
||||
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
||||
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||
Modrinth Servers
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
<template v-if="subscription.interval">
|
||||
{{ subscription.interval }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="mb-4 mt-2 flex items-center gap-1">
|
||||
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }}
|
||||
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template>
|
||||
</div>
|
||||
<div
|
||||
v-for="charge in subscription.charges"
|
||||
:key="charge.id"
|
||||
class="universal-card recessed flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge
|
||||
:color="charge.status === 'succeeded' ? 'green' : 'red'"
|
||||
:type="charge.status"
|
||||
/>
|
||||
⋅
|
||||
{{ charge.type }}
|
||||
⋅
|
||||
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
|
||||
⋅
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template>
|
||||
<div class="mb-4 grid grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<span class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<template v-if="subscription.product.metadata.type === 'midas'">
|
||||
<ModrinthPlusIcon class="h-7 w-min" />
|
||||
</template>
|
||||
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||
<ModrinthServersIcon class="h-7 w-min" />
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
</span>
|
||||
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
|
||||
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
|
||||
{{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
|
||||
dayjs(subscription.created).fromNow()
|
||||
}})
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
|
||||
<ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
|
||||
<nuxt-link
|
||||
:to="`/servers/manage/${subscription.metadata.id}`"
|
||||
target="_blank"
|
||||
class="w-fit"
|
||||
>
|
||||
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<CopyCode :text="subscription.metadata.id" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(charge, index) in subscription.charges"
|
||||
:key="charge.id"
|
||||
class="relative overflow-clip rounded-xl bg-bg px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 top-0 w-1"
|
||||
:class="charge.type === 'refund' ? 'bg-purple' : chargeStatuses[charge.status].color"
|
||||
/>
|
||||
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
<span class="font-bold text-contrast">
|
||||
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
|
||||
<template v-else-if="charge.status === 'failed'"> Failed </template>
|
||||
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
|
||||
<template v-else-if="charge.status === 'processing'"> Processing </template>
|
||||
<template v-else-if="charge.status === 'open'"> Upcoming </template>
|
||||
<template v-else> {{ charge.status }} </template>
|
||||
</span>
|
||||
⋅
|
||||
<span>
|
||||
<template v-if="charge.type === 'refund'"> Refund </template>
|
||||
<template v-else-if="charge.type === 'subscription'">
|
||||
<template v-if="charge.status === 'cancelled'"> Subscription </template>
|
||||
<template v-else-if="index === subscription.charges.length - 1">
|
||||
Started subscription
|
||||
</template>
|
||||
<template v-else> Subscription renewal </template>
|
||||
</template>
|
||||
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
|
||||
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
|
||||
<template v-else> {{ charge.status }} </template>
|
||||
</span>
|
||||
<template v-if="charge.status !== 'cancelled'">
|
||||
⋅
|
||||
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||
<span class="text-secondary">({{ dayjs(charge.due).fromNow() }}) </span>
|
||||
</span>
|
||||
<div
|
||||
v-if="flags.developerMode"
|
||||
class="flex w-full items-center gap-1 text-xs text-secondary"
|
||||
>
|
||||
{{ charge.status }}
|
||||
⋅
|
||||
{{ charge.type }}
|
||||
⋅
|
||||
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
|
||||
⋅
|
||||
{{ dayjs(charge.due).format("YYYY-MM-DD h:mma") }}
|
||||
<template v-if="charge.subscription_interval">
|
||||
⋅ {{ charge.subscription_interval }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
charges.some((x) => x.type === 'refund' && x.parent_charge_id === charge.id)
|
||||
"
|
||||
>
|
||||
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
>
|
||||
<button @click="showRefundModal(charge)">
|
||||
<CurrencyIcon />
|
||||
Refund options
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||
class="btn"
|
||||
@click="showRefundModal(charge)"
|
||||
>
|
||||
Refund charge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,11 +196,22 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, ButtonStyled, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { CheckIcon, XIcon } from "@modrinth/assets";
|
||||
import { Avatar, ButtonStyled, CopyCode, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
|
||||
import { formatCategory, formatPrice } from "@modrinth/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
UserIcon,
|
||||
ModrinthPlusIcon,
|
||||
ServerIcon,
|
||||
ExternalIcon,
|
||||
CurrencyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { products } from "~/generated/state.json";
|
||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
@@ -164,7 +262,10 @@ const subscriptionCharges = computed(() => {
|
||||
return subscriptions.value.map((subscription) => {
|
||||
return {
|
||||
...subscription,
|
||||
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id),
|
||||
charges: charges.value
|
||||
.filter((charge) => charge.subscription_id === subscription.id)
|
||||
.slice()
|
||||
.sort((a, b) => dayjs(b.due).diff(dayjs(a.due))),
|
||||
product: products.find((product) =>
|
||||
product.prices.some((price) => price.id === subscription.price_id),
|
||||
),
|
||||
@@ -212,4 +313,30 @@ async function refundCharge() {
|
||||
}
|
||||
refunding.value = false;
|
||||
}
|
||||
|
||||
const chargeStatuses = {
|
||||
open: {
|
||||
color: "bg-blue",
|
||||
},
|
||||
processing: {
|
||||
color: "bg-orange",
|
||||
},
|
||||
succeeded: {
|
||||
color: "bg-green",
|
||||
},
|
||||
failed: {
|
||||
color: "bg-red",
|
||||
},
|
||||
cancelled: {
|
||||
color: "bg-red",
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 1rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 56rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -164,17 +164,45 @@ const projectTypes = computed(() => {
|
||||
return [...set];
|
||||
});
|
||||
|
||||
function segmentData(data, segmentSize = 900) {
|
||||
return data.reduce((acc, curr, index) => {
|
||||
const segment = Math.floor(index / segmentSize);
|
||||
|
||||
if (!acc[segment]) {
|
||||
acc[segment] = [];
|
||||
}
|
||||
acc[segment].push(curr);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function fetchSegmented(data, createUrl, options = {}) {
|
||||
return Promise.all(segmentData(data).map((ids) => useBaseFetch(createUrl(ids), options))).then(
|
||||
(results) => results.flat(),
|
||||
);
|
||||
}
|
||||
|
||||
function asEncodedJsonArray(data) {
|
||||
return encodeURIComponent(JSON.stringify(data));
|
||||
}
|
||||
|
||||
if (projects.value) {
|
||||
const teamIds = projects.value.map((x) => x.team_id);
|
||||
const organizationIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
|
||||
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
|
||||
|
||||
const url = `teams?ids=${encodeURIComponent(JSON.stringify(teamIds))}`;
|
||||
const orgUrl = `organizations?ids=${encodeURIComponent(JSON.stringify(organizationIds))}`;
|
||||
const { data: result } = await useAsyncData(url, () => useBaseFetch(url));
|
||||
const { data: orgs } = await useAsyncData(orgUrl, () => useBaseFetch(orgUrl, { apiVersion: 3 }));
|
||||
const [{ data: teams }, { data: orgs }] = await Promise.all([
|
||||
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
|
||||
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
|
||||
),
|
||||
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
|
||||
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
if (result.value) {
|
||||
members.value = result.value;
|
||||
if (teams.value) {
|
||||
members.value = teams.value;
|
||||
|
||||
projects.value = projects.value.map((project) => {
|
||||
project.owner = members.value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { getChangelog } from "@modrinth/utils";
|
||||
import { ChangelogEntry } from "@modrinth/ui";
|
||||
import { ChangelogEntry, Timeline } from "@modrinth/ui";
|
||||
import { ChevronLeftIcon } from "@modrinth/assets";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -39,41 +39,13 @@ if (!changelogEntry.value) {
|
||||
>
|
||||
<ChevronLeftIcon /> View full changelog
|
||||
</nuxt-link>
|
||||
<div class="relative flex flex-col gap-4 pb-6">
|
||||
<div class="absolute flex h-full w-4 justify-center">
|
||||
<div class="timeline-indicator" :class="{ first: isFirst }" />
|
||||
</div>
|
||||
<ChangelogEntry :entry="changelogEntry" :first="isFirst" show-type class="relative z-[1]" />
|
||||
</div>
|
||||
<Timeline fade-out-end :fade-out-start="!isFirst" :class="{ '-mt-8': !isFirst }">
|
||||
<ChangelogEntry
|
||||
:entry="changelogEntry"
|
||||
:first="isFirst"
|
||||
show-type
|
||||
:class="{ 'mt-8': !isFirst }"
|
||||
/>
|
||||
</Timeline>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timeline-indicator {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--color-raised-bg) 66%,
|
||||
rgba(255, 255, 255, 0) 0%
|
||||
);
|
||||
background-size: 100% 30px;
|
||||
background-repeat: repeat-y;
|
||||
|
||||
height: calc(100% + 2rem);
|
||||
width: 4px;
|
||||
margin-top: -2rem;
|
||||
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black 8rem,
|
||||
black calc(100% - 8rem),
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
&.first {
|
||||
margin-top: 1rem;
|
||||
|
||||
mask-image: linear-gradient(black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { type Product, getChangelog } from "@modrinth/utils";
|
||||
import { ChangelogEntry } from "@modrinth/ui";
|
||||
import Timeline from "@modrinth/ui/src/components/base/Timeline.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -51,10 +52,7 @@ const changelogEntries = computed(() =>
|
||||
query="filter"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="relative flex flex-col gap-4 pb-6">
|
||||
<div class="absolute flex h-full w-4 justify-center">
|
||||
<div class="timeline-indicator" />
|
||||
</div>
|
||||
<Timeline fade-out-end>
|
||||
<ChangelogEntry
|
||||
v-for="(entry, index) in changelogEntries"
|
||||
:key="entry.date"
|
||||
@@ -62,25 +60,6 @@ const changelogEntries = computed(() =>
|
||||
:first="index === 0"
|
||||
:show-type="filter === undefined"
|
||||
has-link
|
||||
class="relative z-[1]"
|
||||
/>
|
||||
</div>
|
||||
</Timeline>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timeline-indicator {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--color-raised-bg) 66%,
|
||||
rgba(255, 255, 255, 0) 0%
|
||||
);
|
||||
background-size: 100% 30px;
|
||||
background-repeat: repeat-y;
|
||||
margin-top: 1rem;
|
||||
|
||||
height: calc(100% - 1rem);
|
||||
width: 4px;
|
||||
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -95,6 +95,7 @@ SENDY_API_KEY=none
|
||||
|
||||
ANALYTICS_ALLOWED_ORIGINS='["http://127.0.0.1:3000", "http://localhost:3000", "https://modrinth.com", "https://www.modrinth.com", "*"]'
|
||||
|
||||
CLICKHOUSE_REPLICATED=false
|
||||
CLICKHOUSE_URL=http://localhost:8123
|
||||
CLICKHOUSE_USER=default
|
||||
CLICKHOUSE_PASSWORD=
|
||||
|
||||
15
apps/labrinth/.sqlx/query-11344e920ea606504c2fdc3c5a3cb1b1e990def66cf260cb5d648cab72cc34f1.json
generated
Normal file
15
apps/labrinth/.sqlx/query-11344e920ea606504c2fdc3c5a3cb1b1e990def66cf260cb5d648cab72cc34f1.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE team_members\n SET\n is_owner = TRUE,\n accepted = TRUE,\n permissions = $2,\n organization_permissions = NULL,\n role = 'Inherited Owner'\n WHERE (id = $1)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "11344e920ea606504c2fdc3c5a3cb1b1e990def66cf260cb5d648cab72cc34f1"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT u.id \n FROM team_members\n INNER JOIN users u ON u.id = team_members.user_id\n WHERE team_id = $1 AND is_owner = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-527291243eb3684e956d7d49c579857ce857ff462c830dd0cb74574f415d4105.json
generated
Normal file
15
apps/labrinth/.sqlx/query-527291243eb3684e956d7d49c579857ce857ff462c830dd0cb74574f415d4105.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM version_fields\n WHERE version_id = $1\n AND field_id = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "527291243eb3684e956d7d49c579857ce857ff462c830dd0cb74574f415d4105"
|
||||
}
|
||||
22
apps/labrinth/.sqlx/query-96ebe21d1430779e88dcaf8872a8c939b3889f91df9a0e404d4c63d466869fe5.json
generated
Normal file
22
apps/labrinth/.sqlx/query-96ebe21d1430779e88dcaf8872a8c939b3889f91df9a0e404d4c63d466869fe5.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT u.id\n FROM team_members\n INNER JOIN users u ON u.id = team_members.user_id\n WHERE team_id = $1 AND is_owner = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "96ebe21d1430779e88dcaf8872a8c939b3889f91df9a0e404d4c63d466869fe5"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM version_fields \n WHERE version_id = $1\n AND field_id = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17\n )\n ",
|
||||
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -26,5 +26,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f899b378fad8fcfa1ebf527146b565b7c4466205e0bfd84f299123329926fe3f"
|
||||
"hash": "bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE team_members\n SET \n is_owner = TRUE,\n accepted = TRUE,\n permissions = $2,\n organization_permissions = NULL,\n role = 'Inherited Owner'\n WHERE (id = $1)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dc64653d72645b76e42a1834124ce3f9225c5b6b8b941812167b3b7002bfdb2a"
|
||||
}
|
||||
@@ -17,7 +17,7 @@ actix-multipart = "0.6.1"
|
||||
actix-cors = "0.7.0"
|
||||
actix-ws = "0.3.0"
|
||||
actix-files = "0.6.5"
|
||||
actix-web-prom = { version = "0.8.0", features = ["process"] }
|
||||
actix-web-prom = { version = "0.9.0", features = ["process"] }
|
||||
governor = "0.6.3"
|
||||
|
||||
tokio = { version = "1.35.1", features = ["sync"] }
|
||||
@@ -36,8 +36,10 @@ reqwest = { version = "0.11.18", features = ["json", "multipart"] }
|
||||
hyper = { version = "0.14", features = ["full"] }
|
||||
hyper-tls = "0.5.0"
|
||||
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1.0"
|
||||
serde_cbor = "0.11"
|
||||
serde_with = "3.0.0"
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
yaserde = "0.12.0"
|
||||
@@ -74,6 +76,7 @@ dotenvy = "0.15.7"
|
||||
log = "0.4.20"
|
||||
env_logger = "0.10.1"
|
||||
thiserror = "1.0.56"
|
||||
either = "1.13"
|
||||
|
||||
sqlx = { version = "0.8.2", features = [
|
||||
"runtime-tokio-rustls",
|
||||
@@ -124,6 +127,8 @@ async-stripe = { version = "0.39.1", features = ["runtime-tokio-hyper-rustls"] }
|
||||
rusty-money = "0.4.1"
|
||||
json-patch = "*"
|
||||
|
||||
ariadne = { path = "../../packages/ariadne" }
|
||||
|
||||
[dev-dependencies]
|
||||
actix-http = "3.4.0"
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/labrinth
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
ENV SQLX_OFFLINE=true
|
||||
COPY apps/labrinth/.sqlx/ .sqlx/
|
||||
RUN cargo build --release --package labrinth
|
||||
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
@@ -13,15 +15,16 @@ LABEL org.opencontainers.image.description="Modrinth API"
|
||||
LABEL org.opencontainers.image.licenses=AGPL-3.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl dumb-init \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
|
||||
COPY --from=build /usr/src/labrinth/migrations/* /labrinth/migrations/
|
||||
COPY --from=build /usr/src/labrinth/assets /labrinth/assets
|
||||
COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
|
||||
COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
|
||||
WORKDIR /labrinth
|
||||
|
||||
CMD /labrinth/labrinth
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["/labrinth/labrinth"]
|
||||
|
||||
@@ -34,7 +34,7 @@ pub enum AuthenticationError {
|
||||
#[error("Error uploading user profile picture")]
|
||||
FileHosting(#[from] FileHostingError),
|
||||
#[error("Error while decoding PAT: {0}")]
|
||||
Decoding(#[from] crate::models::ids::DecodingError),
|
||||
Decoding(#[from] ariadne::ids::DecodingError),
|
||||
#[error("{0}")]
|
||||
Mail(#[from] email::MailError),
|
||||
#[error("Invalid Authentication Credentials")]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::ValidatedRedirectUri;
|
||||
use crate::auth::AuthenticationError;
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::ids::DecodingError;
|
||||
use actix_web::http::{header::LOCATION, StatusCode};
|
||||
use actix_web::HttpResponse;
|
||||
use ariadne::ids::DecodingError;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[error("{}", .error_type)]
|
||||
|
||||
@@ -35,10 +35,24 @@ pub async fn init_client_with_database(
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
let clickhouse_replicated =
|
||||
dotenvy::var("CLICKHOUSE_REPLICATED").unwrap() == "true";
|
||||
let cluster_line = if clickhouse_replicated {
|
||||
"ON cluster '{cluster}'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let engine = if clickhouse_replicated {
|
||||
"ReplicatedMergeTree('/clickhouse/{installation}/{cluster}/tables/{shard}/{database}/{table}', '{replica}')"
|
||||
} else {
|
||||
"MergeTree()"
|
||||
};
|
||||
|
||||
client
|
||||
.query(&format!(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS {database}.views
|
||||
CREATE TABLE IF NOT EXISTS {database}.views {cluster_line}
|
||||
(
|
||||
recorded DateTime64(4),
|
||||
domain String,
|
||||
@@ -53,8 +67,9 @@ pub async fn init_client_with_database(
|
||||
user_agent String,
|
||||
headers Array(Tuple(String, String))
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ENGINE = {engine}
|
||||
PRIMARY KEY (project_id, recorded, ip)
|
||||
SETTINGS index_granularity = 8192
|
||||
"
|
||||
))
|
||||
.execute()
|
||||
@@ -63,7 +78,7 @@ pub async fn init_client_with_database(
|
||||
client
|
||||
.query(&format!(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS {database}.downloads
|
||||
CREATE TABLE IF NOT EXISTS {database}.downloads {cluster_line}
|
||||
(
|
||||
recorded DateTime64(4),
|
||||
domain String,
|
||||
@@ -78,8 +93,9 @@ pub async fn init_client_with_database(
|
||||
user_agent String,
|
||||
headers Array(Tuple(String, String))
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ENGINE = {engine}
|
||||
PRIMARY KEY (project_id, recorded, ip)
|
||||
SETTINGS index_granularity = 8192
|
||||
"
|
||||
))
|
||||
.execute()
|
||||
@@ -88,7 +104,7 @@ pub async fn init_client_with_database(
|
||||
client
|
||||
.query(&format!(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS {database}.playtime
|
||||
CREATE TABLE IF NOT EXISTS {database}.playtime {cluster_line}
|
||||
(
|
||||
recorded DateTime64(4),
|
||||
seconds UInt64,
|
||||
@@ -101,8 +117,9 @@ pub async fn init_client_with_database(
|
||||
game_version String,
|
||||
parent UInt64
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ENGINE = {engine}
|
||||
PRIMARY KEY (project_id, recorded, user_id)
|
||||
SETTINGS index_granularity = 8192
|
||||
"
|
||||
))
|
||||
.execute()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::DatabaseError;
|
||||
use crate::models::ids::base62_impl::to_base62;
|
||||
use crate::models::ids::{random_base62_rng, random_base62_rng_range};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::{random_base62_rng, random_base62_rng_range};
|
||||
use censor::Censor;
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::{
|
||||
database::redis::RedisPool, models::ids::base62_impl::parse_base62,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use dashmap::DashMap;
|
||||
use futures::TryStreamExt;
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::ids::*;
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::pats::Scopes;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use futures::TryStreamExt;
|
||||
|
||||
@@ -6,8 +6,8 @@ use super::{ids::*, User};
|
||||
use crate::database::models;
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::projects::{MonetizationStatus, ProjectStatus};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use futures::TryStreamExt;
|
||||
@@ -300,7 +300,7 @@ impl Project {
|
||||
slug, color, monetization_status, organization_id
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13,
|
||||
LOWER($14), $15, $16, $17
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::ids::*;
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::{CollectionId, ReportId, ThreadId};
|
||||
use crate::database::models;
|
||||
use crate::database::models::{DatabaseError, OrganizationId};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
use crate::models::users::Badges;
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::models::DatabaseError;
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use dashmap::DashMap;
|
||||
use deadpool_redis::{Config, Runtime};
|
||||
|
||||
@@ -297,7 +297,7 @@ pub fn app_setup(
|
||||
}
|
||||
|
||||
let ip_salt = Pepper {
|
||||
pepper: models::ids::Base62Id(models::ids::random_base62(11))
|
||||
pepper: ariadne::ids::Base62Id(ariadne::ids::random_base62(11))
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
@@ -473,6 +473,7 @@ pub fn check_env_vars() -> bool {
|
||||
failed |= true;
|
||||
}
|
||||
|
||||
failed |= check_var::<bool>("CLICKHOUSE_REPLICATED");
|
||||
failed |= check_var::<String>("CLICKHOUSE_URL");
|
||||
failed |= check_var::<String>("CLICKHOUSE_USER");
|
||||
failed |= check_var::<String>("CLICKHOUSE_PASSWORD");
|
||||
|
||||
@@ -92,7 +92,10 @@ async fn main() -> std::io::Result<()> {
|
||||
|
||||
let prometheus = PrometheusMetricsBuilder::new("labrinth")
|
||||
.endpoint("/metrics")
|
||||
.exclude_regex(r"^/api/v1/.*$")
|
||||
.exclude_regex(r"^/maven/.*$")
|
||||
.exclude("/_internal/launcher_socket")
|
||||
.mask_unmatched_patterns("UNKNOWN")
|
||||
.build()
|
||||
.expect("Failed to create prometheus metrics middleware");
|
||||
|
||||
|
||||
@@ -12,118 +12,14 @@ pub use super::sessions::SessionId;
|
||||
pub use super::teams::TeamId;
|
||||
pub use super::threads::ThreadId;
|
||||
pub use super::threads::ThreadMessageId;
|
||||
pub use super::users::UserId;
|
||||
pub use crate::models::billing::{
|
||||
ChargeId, ProductId, ProductPriceId, UserSubscriptionId,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use ariadne::ids::base62_id_impl;
|
||||
pub use ariadne::ids::Base62Id;
|
||||
pub use ariadne::users::UserId;
|
||||
|
||||
/// Generates a random 64 bit integer that is exactly `n` characters
|
||||
/// long when encoded as base62.
|
||||
///
|
||||
/// Uses `rand`'s thread rng on every call.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This method panics if `n` is 0 or greater than 11, since a `u64`
|
||||
/// can only represent up to 11 character base62 strings
|
||||
#[inline]
|
||||
pub fn random_base62(n: usize) -> u64 {
|
||||
random_base62_rng(&mut rand::thread_rng(), n)
|
||||
}
|
||||
|
||||
/// Generates a random 64 bit integer that is exactly `n` characters
|
||||
/// long when encoded as base62, using the given rng.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This method panics if `n` is 0 or greater than 11, since a `u64`
|
||||
/// can only represent up to 11 character base62 strings
|
||||
pub fn random_base62_rng<R: rand::RngCore>(rng: &mut R, n: usize) -> u64 {
|
||||
random_base62_rng_range(rng, n, n)
|
||||
}
|
||||
|
||||
pub fn random_base62_rng_range<R: rand::RngCore>(
|
||||
rng: &mut R,
|
||||
n_min: usize,
|
||||
n_max: usize,
|
||||
) -> u64 {
|
||||
use rand::Rng;
|
||||
assert!(n_min > 0 && n_max <= 11 && n_min <= n_max);
|
||||
// gen_range is [low, high): max value is `MULTIPLES[n] - 1`,
|
||||
// which is n characters long when encoded
|
||||
rng.gen_range(MULTIPLES[n_min - 1]..MULTIPLES[n_max])
|
||||
}
|
||||
|
||||
const MULTIPLES: [u64; 12] = [
|
||||
1,
|
||||
62,
|
||||
62 * 62,
|
||||
62 * 62 * 62,
|
||||
62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62,
|
||||
u64::MAX,
|
||||
];
|
||||
|
||||
/// An ID encoded as base62 for use in the API.
|
||||
///
|
||||
/// All ids should be random and encode to 8-10 character base62 strings,
|
||||
/// to avoid enumeration and other attacks.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub struct Base62Id(pub u64);
|
||||
|
||||
/// An error decoding a number from base62.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DecodingError {
|
||||
/// Encountered a non-base62 character in a base62 string
|
||||
#[error("Invalid character {0:?} in base62 encoding")]
|
||||
InvalidBase62(char),
|
||||
/// Encountered integer overflow when decoding a base62 id.
|
||||
#[error("Base62 decoding overflowed")]
|
||||
Overflow,
|
||||
}
|
||||
|
||||
macro_rules! from_base62id {
|
||||
($($struct:ty, $con:expr;)+) => {
|
||||
$(
|
||||
impl From<Base62Id> for $struct {
|
||||
fn from(id: Base62Id) -> $struct {
|
||||
$con(id.0)
|
||||
}
|
||||
}
|
||||
impl From<$struct> for Base62Id {
|
||||
fn from(id: $struct) -> Base62Id {
|
||||
Base62Id(id.0)
|
||||
}
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! impl_base62_display {
|
||||
($struct:ty) => {
|
||||
impl std::fmt::Display for $struct {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&base62_impl::to_base62(self.0))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
impl_base62_display!(Base62Id);
|
||||
|
||||
macro_rules! base62_id_impl {
|
||||
($struct:ty, $cons:expr) => {
|
||||
from_base62id!($struct, $cons;);
|
||||
impl_base62_display!($struct);
|
||||
}
|
||||
}
|
||||
base62_id_impl!(ProjectId, ProjectId);
|
||||
base62_id_impl!(UserId, UserId);
|
||||
base62_id_impl!(VersionId, VersionId);
|
||||
base62_id_impl!(CollectionId, CollectionId);
|
||||
base62_id_impl!(TeamId, TeamId);
|
||||
@@ -143,91 +39,3 @@ base62_id_impl!(ProductId, ProductId);
|
||||
base62_id_impl!(ProductPriceId, ProductPriceId);
|
||||
base62_id_impl!(UserSubscriptionId, UserSubscriptionId);
|
||||
base62_id_impl!(ChargeId, ChargeId);
|
||||
|
||||
pub mod base62_impl {
|
||||
use serde::de::{self, Deserializer, Visitor};
|
||||
use serde::ser::Serializer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Base62Id, DecodingError};
|
||||
|
||||
impl<'de> Deserialize<'de> for Base62Id {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct Base62Visitor;
|
||||
|
||||
impl Visitor<'_> for Base62Visitor {
|
||||
type Value = Base62Id;
|
||||
|
||||
fn expecting(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter,
|
||||
) -> std::fmt::Result {
|
||||
formatter.write_str("a base62 string id")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, string: &str) -> Result<Base62Id, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
parse_base62(string).map(Base62Id).map_err(E::custom)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(Base62Visitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Base62Id {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&to_base62(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
const BASE62_CHARS: [u8; 62] =
|
||||
*b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
pub fn to_base62(mut num: u64) -> String {
|
||||
let length = (num as f64).log(62.0).ceil() as usize;
|
||||
let mut output = String::with_capacity(length);
|
||||
|
||||
while num > 0 {
|
||||
// Could be done more efficiently, but requires byte
|
||||
// manipulation of strings & Vec<u8> -> String conversion
|
||||
output.insert(0, BASE62_CHARS[(num % 62) as usize] as char);
|
||||
num /= 62;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn parse_base62(string: &str) -> Result<u64, DecodingError> {
|
||||
let mut num: u64 = 0;
|
||||
for c in string.chars() {
|
||||
let next_digit;
|
||||
if c.is_ascii_digit() {
|
||||
next_digit = (c as u8 - b'0') as u64;
|
||||
} else if c.is_ascii_uppercase() {
|
||||
next_digit = 10 + (c as u8 - b'A') as u64;
|
||||
} else if c.is_ascii_lowercase() {
|
||||
next_digit = 36 + (c as u8 - b'a') as u64;
|
||||
} else {
|
||||
return Err(DecodingError::InvalidBase62(c));
|
||||
}
|
||||
|
||||
// We don't want this panicking or wrapping on integer overflow
|
||||
if let Some(n) =
|
||||
num.checked_mul(62).and_then(|n| n.checked_add(next_digit))
|
||||
{
|
||||
num = n;
|
||||
} else {
|
||||
return Err(DecodingError::Overflow);
|
||||
}
|
||||
}
|
||||
Ok(num)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ impl From<DBNotification> for Notification {
|
||||
name.clone(),
|
||||
text.clone(),
|
||||
link.clone(),
|
||||
actions.clone().into_iter().map(Into::into).collect(),
|
||||
actions.clone().into_iter().collect(),
|
||||
),
|
||||
NotificationBody::Unknown => {
|
||||
("".to_string(), "".to_string(), "#".to_string(), vec![])
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
use super::ids::Base62Id;
|
||||
use crate::{auth::AuthProvider, bitflags_serde_impl};
|
||||
pub use ariadne::users::{UserId, UserStatus};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct UserId(pub u64);
|
||||
|
||||
pub const DELETED_USER: UserId = UserId(127155982985829);
|
||||
|
||||
bitflags::bitflags! {
|
||||
@@ -211,10 +206,3 @@ impl UserFriend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct UserStatus {
|
||||
pub user_id: UserId,
|
||||
pub profile_name: Option<String>,
|
||||
pub last_update: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,68 @@
|
||||
//! "Database" for Hydra
|
||||
|
||||
use crate::models::users::{UserId, UserStatus};
|
||||
use actix_ws::Session;
|
||||
use dashmap::DashMap;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use std::sync::atomic::AtomicU32;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type SocketId = u32;
|
||||
|
||||
pub struct ActiveSockets {
|
||||
pub auth_sockets: DashMap<UserId, (UserStatus, Session)>,
|
||||
pub sockets: DashMap<SocketId, ActiveSocket>,
|
||||
pub sockets_by_user_id: DashMap<UserId, DashSet<SocketId>>,
|
||||
pub next_socket_id: AtomicU32,
|
||||
pub tunnel_sockets: DashMap<Uuid, TunnelSocket>,
|
||||
}
|
||||
|
||||
impl Default for ActiveSockets {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth_sockets: DashMap::new(),
|
||||
sockets: DashMap::new(),
|
||||
sockets_by_user_id: DashMap::new(),
|
||||
next_socket_id: AtomicU32::new(0),
|
||||
tunnel_sockets: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveSockets {
|
||||
pub fn get_status(&self, user: UserId) -> Option<UserStatus> {
|
||||
self.sockets_by_user_id
|
||||
.get(&user)
|
||||
.and_then(|x| x.iter().next().and_then(|x| self.sockets.get(&*x)))
|
||||
.map(|x| x.status.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ActiveSocket {
|
||||
pub status: UserStatus,
|
||||
pub socket: Session,
|
||||
pub owned_tunnel_sockets: DashSet<Uuid>,
|
||||
}
|
||||
|
||||
impl ActiveSocket {
|
||||
pub fn new(status: UserStatus, session: Session) -> Self {
|
||||
Self {
|
||||
status,
|
||||
socket: session,
|
||||
owned_tunnel_sockets: DashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TunnelSocket {
|
||||
pub owner: SocketId,
|
||||
pub socket_type: TunnelSocketType,
|
||||
}
|
||||
|
||||
impl TunnelSocket {
|
||||
pub fn new(owner: SocketId, socket_type: TunnelSocketType) -> Self {
|
||||
Self { owner, socket_type }
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TunnelSocketType {
|
||||
Listening,
|
||||
Connected { connected_to: Uuid },
|
||||
}
|
||||
|
||||
@@ -74,11 +74,10 @@ pub async fn count_download(
|
||||
let project_id: crate::database::models::ids::ProjectId =
|
||||
download_body.project_id.into();
|
||||
|
||||
let id_option = crate::models::ids::base62_impl::parse_base62(
|
||||
&download_body.version_name,
|
||||
)
|
||||
.ok()
|
||||
.map(|x| x as i64);
|
||||
let id_option =
|
||||
ariadne::ids::base62_impl::parse_base62(&download_body.version_name)
|
||||
.ok()
|
||||
.map(|x| x as i64);
|
||||
|
||||
let (version_id, project_id) = if let Some(version) = sqlx::query!(
|
||||
"
|
||||
|
||||
@@ -10,12 +10,12 @@ use crate::models::billing::{
|
||||
Product, ProductMetadata, ProductPrice, SubscriptionMetadata,
|
||||
SubscriptionStatus, UserSubscription,
|
||||
};
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::Badges;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use chrono::Utc;
|
||||
use log::{info, warn};
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
|
||||
@@ -4,8 +4,6 @@ use crate::auth::{get_user_from_headers, AuthProvider, AuthenticationError};
|
||||
use crate::database::models::flow_item::Flow;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
use crate::models::ids::random_base62_rng;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::{Badges, Role};
|
||||
use crate::queue::session::AuthQueue;
|
||||
@@ -20,6 +18,8 @@ use actix_web::web::{scope, Data, Query, ServiceConfig};
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use ariadne::ids::random_base62_rng;
|
||||
use base64::Engine;
|
||||
use chrono::{Duration, Utc};
|
||||
use rand_chacha::rand_core::SeedableRng;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::ApiError;
|
||||
use crate::database;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::random_base62;
|
||||
use crate::models::projects::ProjectStatus;
|
||||
use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use ariadne::ids::random_base62;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -2,40 +2,32 @@ use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::auth::AuthenticationError;
|
||||
use crate::database::models::friend_item::FriendItem;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::UserId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::{User, UserStatus};
|
||||
use crate::models::users::User;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::queue::socket::ActiveSockets;
|
||||
use crate::queue::socket::{
|
||||
ActiveSocket, ActiveSockets, SocketId, TunnelSocketType,
|
||||
};
|
||||
use crate::routes::ApiError;
|
||||
use actix_web::web::{Data, Payload};
|
||||
use actix_web::{get, web, HttpRequest, HttpResponse};
|
||||
use actix_ws::Message;
|
||||
use ariadne::ids::UserId;
|
||||
use ariadne::networking::message::{
|
||||
ClientToServerMessage, ServerToClientMessage,
|
||||
};
|
||||
use ariadne::users::UserStatus;
|
||||
use chrono::Utc;
|
||||
use either::Either;
|
||||
use futures_util::{StreamExt, TryStreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(ws_init);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientToServerMessage {
|
||||
StatusUpdate { profile_name: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerToClientMessage {
|
||||
StatusUpdate { status: UserStatus },
|
||||
UserOffline { id: UserId },
|
||||
FriendStatuses { statuses: Vec<UserStatus> },
|
||||
FriendRequest { from: UserId },
|
||||
FriendRequestRejected { from: UserId },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LauncherHeartbeatInit {
|
||||
code: String,
|
||||
@@ -71,10 +63,6 @@ pub async fn ws_init(
|
||||
|
||||
let user = User::from_full(db_user);
|
||||
|
||||
if let Some((_, (_, session))) = db.auth_sockets.remove(&user.id) {
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
let (res, mut session, msg_stream) = match actix_ws::handle(&req, body) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Ok(e.error_response()),
|
||||
@@ -94,8 +82,8 @@ pub async fn ws_init(
|
||||
friends
|
||||
.iter()
|
||||
.filter_map(|x| {
|
||||
db.auth_sockets.get(
|
||||
&if x.user_id == user.id.into() {
|
||||
db.get_status(
|
||||
if x.user_id == user.id.into() {
|
||||
x.friend_id
|
||||
} else {
|
||||
x.user_id
|
||||
@@ -103,7 +91,6 @@ pub async fn ws_init(
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.map(|x| x.value().0.clone())
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -117,7 +104,17 @@ pub async fn ws_init(
|
||||
)?)
|
||||
.await;
|
||||
|
||||
db.auth_sockets.insert(user.id, (status.clone(), session));
|
||||
let db = db.clone();
|
||||
let socket_id = db.next_socket_id.fetch_add(1, Ordering::Relaxed);
|
||||
db.sockets
|
||||
.insert(socket_id, ActiveSocket::new(status.clone(), session));
|
||||
db.sockets_by_user_id
|
||||
.entry(user.id)
|
||||
.or_default()
|
||||
.insert(socket_id);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
log::info!("Connection {socket_id} opened by {}", user.id);
|
||||
|
||||
broadcast_friends(
|
||||
user.id,
|
||||
@@ -133,68 +130,182 @@ pub async fn ws_init(
|
||||
actix_web::rt::spawn(async move {
|
||||
// receive messages from websocket
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg {
|
||||
let message = match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if let Ok(message) =
|
||||
serde_json::from_str::<ClientToServerMessage>(&text)
|
||||
{
|
||||
match message {
|
||||
ClientToServerMessage::StatusUpdate {
|
||||
profile_name,
|
||||
} => {
|
||||
if let Some(mut pair) =
|
||||
db.auth_sockets.get_mut(&user.id)
|
||||
{
|
||||
let (status, _) = pair.value_mut();
|
||||
ClientToServerMessage::deserialize(Either::Left(&text))
|
||||
}
|
||||
|
||||
if status
|
||||
.profile_name
|
||||
.as_ref()
|
||||
.map(|x| x.len() > 64)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
status.profile_name = profile_name;
|
||||
status.last_update = Utc::now();
|
||||
|
||||
let user_status = status.clone();
|
||||
// We drop the pair to avoid holding the lock for too long
|
||||
drop(pair);
|
||||
|
||||
let _ = broadcast_friends(
|
||||
user.id,
|
||||
ServerToClientMessage::StatusUpdate {
|
||||
status: user_status,
|
||||
},
|
||||
&pool,
|
||||
&db,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Binary(bytes)) => {
|
||||
ClientToServerMessage::deserialize(Either::Right(&bytes))
|
||||
}
|
||||
|
||||
Ok(Message::Close(_)) => {
|
||||
let _ = close_socket(user.id, &pool, &db).await;
|
||||
let _ = close_socket(socket_id, &pool, &db).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
Ok(Message::Ping(msg)) => {
|
||||
if let Some(socket) = db.auth_sockets.get(&user.id) {
|
||||
let (_, socket) = socket.value();
|
||||
let _ = socket.clone().pong(&msg).await;
|
||||
if let Some(socket) = db.sockets.get(&socket_id) {
|
||||
let _ = socket.socket.clone().pong(&msg).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if message.is_err() {
|
||||
continue;
|
||||
}
|
||||
let message = message.unwrap();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if !message.is_binary() {
|
||||
log::info!("Received message from {socket_id}: {:?}", message);
|
||||
}
|
||||
|
||||
match message {
|
||||
ClientToServerMessage::StatusUpdate { profile_name } => {
|
||||
if let Some(mut pair) = db.sockets.get_mut(&socket_id) {
|
||||
let ActiveSocket { status, .. } = pair.value_mut();
|
||||
|
||||
if status
|
||||
.profile_name
|
||||
.as_ref()
|
||||
.map(|x| x.len() > 64)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
status.profile_name = profile_name;
|
||||
status.last_update = Utc::now();
|
||||
|
||||
let user_status = status.clone();
|
||||
// We drop the pair to avoid holding the lock for too long
|
||||
drop(pair);
|
||||
|
||||
let _ = broadcast_friends(
|
||||
user.id,
|
||||
ServerToClientMessage::StatusUpdate {
|
||||
status: user_status,
|
||||
},
|
||||
&pool,
|
||||
&db,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
ClientToServerMessage::SocketListen { .. } => {
|
||||
// TODO: Listen to socket
|
||||
// The code below probably won't need changes, but there's no way to connect to
|
||||
// a tunnel socket yet, so we shouldn't be storing them
|
||||
|
||||
// let Some(active_socket) = db.sockets.get(&socket_id) else {
|
||||
// return;
|
||||
// };
|
||||
// let Vacant(entry) = db.tunnel_sockets.entry(socket) else {
|
||||
// continue;
|
||||
// };
|
||||
// entry.insert(TunnelSocket::new(
|
||||
// socket_id,
|
||||
// TunnelSocketType::Listening,
|
||||
// ));
|
||||
// active_socket.owned_tunnel_sockets.insert(socket);
|
||||
// let _ = broadcast_friends(
|
||||
// user.id,
|
||||
// ServerToClientMessage::FriendSocketListening {
|
||||
// user: user.id,
|
||||
// socket,
|
||||
// },
|
||||
// &pool,
|
||||
// &db,
|
||||
// None,
|
||||
// )
|
||||
// .await;
|
||||
}
|
||||
ClientToServerMessage::SocketClose { socket } => {
|
||||
let Some(active_socket) = db.sockets.get(&socket_id) else {
|
||||
return;
|
||||
};
|
||||
if active_socket
|
||||
.owned_tunnel_sockets
|
||||
.remove(&socket)
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some((_, tunnel_socket)) =
|
||||
db.tunnel_sockets.remove(&socket)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match tunnel_socket.socket_type {
|
||||
TunnelSocketType::Listening => {
|
||||
let _ = broadcast_friends(
|
||||
user.id,
|
||||
ServerToClientMessage::FriendSocketStoppedListening { user: user.id },
|
||||
&pool,
|
||||
&db,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
TunnelSocketType::Connected { connected_to } => {
|
||||
let Some((_, other)) =
|
||||
db.tunnel_sockets.remove(&connected_to)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(other_user) = db.sockets.get(&other.owner)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let _ = send_message(
|
||||
&other_user,
|
||||
&ServerToClientMessage::SocketClosed { socket },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientToServerMessage::SocketSend { socket, data } => {
|
||||
let Some(tunnel_socket) = db.tunnel_sockets.get(&socket)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if tunnel_socket.owner != socket_id {
|
||||
continue;
|
||||
}
|
||||
let TunnelSocketType::Connected { connected_to } =
|
||||
tunnel_socket.socket_type
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(other_tunnel) =
|
||||
db.tunnel_sockets.get(&connected_to)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(other_user) = db.sockets.get(&other_tunnel.owner)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let _ = send_message(
|
||||
&other_user,
|
||||
&ServerToClientMessage::SocketData {
|
||||
socket: connected_to,
|
||||
data,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = close_socket(user.id, &pool, &db).await;
|
||||
let _ = close_socket(socket_id, &pool, &db).await;
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
@@ -207,6 +318,7 @@ pub async fn broadcast_friends(
|
||||
sockets: &ActiveSockets,
|
||||
friends: Option<Vec<FriendItem>>,
|
||||
) -> Result<(), crate::database::models::DatabaseError> {
|
||||
// FIXME Probably shouldn't be using database errors for this. Maybe ApiError?
|
||||
let friends = if let Some(friends) = friends {
|
||||
friends
|
||||
} else {
|
||||
@@ -221,11 +333,46 @@ pub async fn broadcast_friends(
|
||||
};
|
||||
|
||||
if friend.accepted {
|
||||
if let Some(socket) = sockets.auth_sockets.get(&friend_id.into()) {
|
||||
let (_, socket) = socket.value();
|
||||
if let Some(socket_ids) =
|
||||
sockets.sockets_by_user_id.get(&friend_id.into())
|
||||
{
|
||||
for socket_id in socket_ids.iter() {
|
||||
if let Some(socket) = sockets.sockets.get(&socket_id) {
|
||||
let _ = send_message(socket.value(), &message).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ =
|
||||
socket.clone().text(serde_json::to_string(&message)?).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
socket: &ActiveSocket,
|
||||
message: &ServerToClientMessage,
|
||||
) -> Result<(), crate::database::models::DatabaseError> {
|
||||
let mut socket = socket.socket.clone();
|
||||
|
||||
// FIXME Probably shouldn't swallow sending errors
|
||||
let _ = match message.serialize() {
|
||||
Ok(Either::Left(text)) => socket.text(text).await,
|
||||
Ok(Either::Right(bytes)) => socket.binary(bytes).await,
|
||||
Err(_) => Ok(()), // TODO: Maybe should log these? Though it is the backend
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_message_to_user(
|
||||
db: &ActiveSockets,
|
||||
user: UserId,
|
||||
message: &ServerToClientMessage,
|
||||
) -> Result<(), crate::database::models::DatabaseError> {
|
||||
if let Some(socket_ids) = db.sockets_by_user_id.get(&user) {
|
||||
for socket_id in socket_ids.iter() {
|
||||
if let Some(socket) = db.sockets.get(&socket_id) {
|
||||
send_message(&socket, message).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,21 +381,66 @@ pub async fn broadcast_friends(
|
||||
}
|
||||
|
||||
pub async fn close_socket(
|
||||
id: UserId,
|
||||
id: SocketId,
|
||||
pool: &PgPool,
|
||||
sockets: &ActiveSockets,
|
||||
db: &ActiveSockets,
|
||||
) -> Result<(), crate::database::models::DatabaseError> {
|
||||
if let Some((_, (_, socket))) = sockets.auth_sockets.remove(&id) {
|
||||
let _ = socket.close(None).await;
|
||||
if let Some((_, socket)) = db.sockets.remove(&id) {
|
||||
let user_id = socket.status.user_id;
|
||||
db.sockets_by_user_id.remove_if(&user_id, |_, sockets| {
|
||||
sockets.remove(&id);
|
||||
sockets.is_empty()
|
||||
});
|
||||
|
||||
let _ = socket.socket.close(None).await;
|
||||
|
||||
broadcast_friends(
|
||||
id,
|
||||
ServerToClientMessage::UserOffline { id },
|
||||
user_id,
|
||||
ServerToClientMessage::UserOffline { id: user_id },
|
||||
pool,
|
||||
sockets,
|
||||
db,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for owned_socket in socket.owned_tunnel_sockets {
|
||||
let Some((_, tunnel_socket)) =
|
||||
db.tunnel_sockets.remove(&owned_socket)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match tunnel_socket.socket_type {
|
||||
TunnelSocketType::Listening => {
|
||||
let _ = broadcast_friends(
|
||||
user_id,
|
||||
ServerToClientMessage::SocketClosed {
|
||||
socket: owned_socket,
|
||||
},
|
||||
pool,
|
||||
db,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
TunnelSocketType::Connected { connected_to } => {
|
||||
let Some((_, other)) =
|
||||
db.tunnel_sockets.remove(&connected_to)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(other_user) = db.sockets.get(&other.owner) else {
|
||||
continue;
|
||||
};
|
||||
let _ = send_message(
|
||||
&other_user,
|
||||
&ServerToClientMessage::SocketClosed {
|
||||
socket: connected_to,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -164,7 +164,7 @@ async fn find_version(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Option<QueryVersion>, ApiError> {
|
||||
let id_option = crate::models::ids::base62_impl::parse_base62(vcoords)
|
||||
let id_option = ariadne::ids::base62_impl::parse_base62(vcoords)
|
||||
.ok()
|
||||
.map(|x| x as i64);
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ pub enum ApiError {
|
||||
#[error("Captcha Error. Try resubmitting the form.")]
|
||||
Turnstile,
|
||||
#[error("Error while decoding Base62: {0}")]
|
||||
Decoding(#[from] crate::models::ids::DecodingError),
|
||||
Decoding(#[from] ariadne::ids::DecodingError),
|
||||
#[error("Image Parsing Error: {0}")]
|
||||
ImageParse(#[from] image::ImageError),
|
||||
#[error("Password Hashing Error: {0}")]
|
||||
|
||||
@@ -6,12 +6,13 @@ use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::models::user_item,
|
||||
models::{
|
||||
ids::{base62_impl::to_base62, ProjectId, VersionId},
|
||||
ids::{ProjectId, VersionId},
|
||||
pats::Scopes,
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::types::PgInterval;
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::database::models::{
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::collections::{Collection, CollectionStatus};
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::ids::{CollectionId, ProjectId};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::session::AuthQueue;
|
||||
@@ -18,6 +17,7 @@ use crate::util::validate::validation_errors_to_string;
|
||||
use crate::{database, models};
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::Utc;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -5,9 +5,10 @@ use crate::models::pats::Scopes;
|
||||
use crate::models::users::UserFriend;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::queue::socket::ActiveSockets;
|
||||
use crate::routes::internal::statuses::{close_socket, ServerToClientMessage};
|
||||
use crate::routes::internal::statuses::send_message_to_user;
|
||||
use crate::routes::ApiError;
|
||||
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
|
||||
use ariadne::networking::message::ServerToClientMessage;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -76,22 +77,16 @@ pub async fn add_friend(
|
||||
friend_id: UserId,
|
||||
sockets: &ActiveSockets,
|
||||
) -> Result<(), ApiError> {
|
||||
if let Some(pair) = sockets.auth_sockets.get(&user_id.into()) {
|
||||
let (friend_status, _) = pair.value();
|
||||
if let Some(socket) =
|
||||
sockets.auth_sockets.get(&friend_id.into())
|
||||
{
|
||||
let (_, socket) = socket.value();
|
||||
|
||||
let _ = socket
|
||||
.clone()
|
||||
.text(serde_json::to_string(
|
||||
&ServerToClientMessage::StatusUpdate {
|
||||
status: friend_status.clone(),
|
||||
},
|
||||
)?)
|
||||
.await;
|
||||
}
|
||||
if let Some(friend_status) = sockets.get_status(user_id.into())
|
||||
{
|
||||
send_message_to_user(
|
||||
sockets,
|
||||
friend_id.into(),
|
||||
&ServerToClientMessage::StatusUpdate {
|
||||
status: friend_status.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -121,20 +116,12 @@ pub async fn add_friend(
|
||||
.insert(&mut transaction)
|
||||
.await?;
|
||||
|
||||
if let Some(socket) = db.auth_sockets.get(&friend.id.into()) {
|
||||
let (_, socket) = socket.value();
|
||||
|
||||
if socket
|
||||
.clone()
|
||||
.text(serde_json::to_string(
|
||||
&ServerToClientMessage::FriendRequest { from: user.id },
|
||||
)?)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
close_socket(user.id, &pool, &db).await?;
|
||||
}
|
||||
}
|
||||
send_message_to_user(
|
||||
&db,
|
||||
friend.id.into(),
|
||||
&ServerToClientMessage::FriendRequest { from: user.id },
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
@@ -178,18 +165,12 @@ pub async fn remove_friend(
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(socket) = db.auth_sockets.get(&friend.id.into()) {
|
||||
let (_, socket) = socket.value();
|
||||
|
||||
let _ = socket
|
||||
.clone()
|
||||
.text(serde_json::to_string(
|
||||
&ServerToClientMessage::FriendRequestRejected {
|
||||
from: user.id,
|
||||
},
|
||||
)?)
|
||||
.await;
|
||||
}
|
||||
send_message_to_user(
|
||||
&db,
|
||||
friend.id.into(),
|
||||
&ServerToClientMessage::FriendRequestRejected { from: user.id },
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
use std::{collections::HashSet, fmt::Display, sync::Arc};
|
||||
|
||||
use actix_web::{
|
||||
delete, get, patch, post,
|
||||
web::{self, scope},
|
||||
HttpRequest, HttpResponse,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use itertools::Itertools;
|
||||
use rand::{distributions::Alphanumeric, Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use validator::Validate;
|
||||
|
||||
use super::ApiError;
|
||||
use crate::{
|
||||
auth::{checks::ValidateAuthorized, get_user_from_headers},
|
||||
@@ -35,13 +22,22 @@ use crate::{
|
||||
util::validate::validation_errors_to_string,
|
||||
};
|
||||
use crate::{
|
||||
file_hosting::FileHost,
|
||||
models::{
|
||||
ids::base62_impl::parse_base62,
|
||||
oauth_clients::DeleteOAuthClientQueryParam,
|
||||
},
|
||||
file_hosting::FileHost, models::oauth_clients::DeleteOAuthClientQueryParam,
|
||||
util::routes::read_from_payload,
|
||||
};
|
||||
use actix_web::{
|
||||
delete, get, patch, post,
|
||||
web::{self, scope},
|
||||
HttpRequest, HttpResponse,
|
||||
};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::Utc;
|
||||
use itertools::Itertools;
|
||||
use rand::{distributions::Alphanumeric, Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient;
|
||||
use crate::models::ids::OAuthClientId as ApiOAuthClientId;
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::database::models::{
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::ids::UserId;
|
||||
use crate::models::organizations::OrganizationId;
|
||||
use crate::models::pats::Scopes;
|
||||
@@ -21,6 +20,7 @@ use crate::util::routes::read_from_payload;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use crate::{database, models};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use futures::TryStreamExt;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -786,7 +786,7 @@ pub async fn organization_projects_add(
|
||||
|
||||
let organization_owner_user_id = sqlx::query!(
|
||||
"
|
||||
SELECT u.id
|
||||
SELECT u.id
|
||||
FROM team_members
|
||||
INNER JOIN users u ON u.id = team_members.user_id
|
||||
WHERE team_id = $1 AND is_owner = TRUE
|
||||
@@ -969,7 +969,7 @@ pub async fn organization_projects_remove(
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE team_members
|
||||
SET
|
||||
SET
|
||||
is_owner = TRUE,
|
||||
accepted = TRUE,
|
||||
permissions = $2,
|
||||
|
||||
@@ -8,7 +8,6 @@ use crate::database::models::{self, image_item, User};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::{FileHost, FileHostingError};
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::ids::base62_impl::to_base62;
|
||||
use crate::models::ids::{ImageId, OrganizationId};
|
||||
use crate::models::images::{Image, ImageContext};
|
||||
use crate::models::pats::Scopes;
|
||||
@@ -28,6 +27,7 @@ use actix_multipart::{Field, Multipart};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::{self, Data};
|
||||
use actix_web::{HttpRequest, HttpResponse};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use chrono::Utc;
|
||||
use futures::stream::StreamExt;
|
||||
use image::ImageError;
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::database::redis::RedisPool;
|
||||
use crate::database::{self, models as db_models};
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models;
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::images::ImageContext;
|
||||
use crate::models::notifications::NotificationBody;
|
||||
use crate::models::pats::Scopes;
|
||||
@@ -30,6 +29,7 @@ use crate::util::img::{delete_old_images, upload_image_optimized};
|
||||
use crate::util::routes::read_from_payload;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::Utc;
|
||||
use futures::TryStreamExt;
|
||||
use itertools::Itertools;
|
||||
|
||||
@@ -6,9 +6,7 @@ use crate::database::models::thread_item::{
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::ImageId;
|
||||
use crate::models::ids::{
|
||||
base62_impl::parse_base62, ProjectId, UserId, VersionId,
|
||||
};
|
||||
use crate::models::ids::{ProjectId, UserId, VersionId};
|
||||
use crate::models::images::{Image, ImageContext};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::reports::{ItemType, Report};
|
||||
@@ -17,6 +15,7 @@ use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::img;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -13,7 +13,6 @@ use crate::database::models::version_item::{DependencyBuilder, LoaderVersion};
|
||||
use crate::database::models::{image_item, Organization};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models;
|
||||
use crate::models::ids::base62_impl::parse_base62;
|
||||
use crate::models::ids::VersionId;
|
||||
use crate::models::images::ImageContext;
|
||||
use crate::models::pats::Scopes;
|
||||
@@ -28,6 +27,7 @@ use crate::search::SearchConfig;
|
||||
use crate::util::img;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
@@ -444,7 +444,7 @@ pub async fn version_edit_helper(
|
||||
.collect::<Vec<i32>>();
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM version_fields
|
||||
DELETE FROM version_fields
|
||||
WHERE version_id = $1
|
||||
AND field_id = ANY($2)
|
||||
",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
pub mod local_import;
|
||||
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::base62_impl::to_base62;
|
||||
use crate::search::{SearchConfig, UploadSearchProject};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use local_import::index_local;
|
||||
use log::info;
|
||||
use meilisearch_sdk::client::{Client, SwapIndexes};
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn get_color_from_img(data: &[u8]) -> Result<Option<u32>, ImageError> {
|
||||
)
|
||||
.ok()
|
||||
.and_then(|x| x.first().copied())
|
||||
.map(|x| (x.r as u32) << 16 | (x.g as u32) << 8 | (x.b as u32));
|
||||
.map(|x| ((x.r as u32) << 16) | ((x.g as u32) << 8) | (x.b as u32));
|
||||
|
||||
Ok(color)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::base62_impl::to_base62;
|
||||
use crate::models::projects::ProjectId;
|
||||
use crate::routes::ApiError;
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use common::permissions::PermissionsTest;
|
||||
use common::permissions::PermissionsTestContext;
|
||||
@@ -7,7 +8,6 @@ use common::{
|
||||
environment::{with_test_environment, TestEnvironment},
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use labrinth::models::ids::base62_impl::parse_base62;
|
||||
use labrinth::models::teams::ProjectPermissions;
|
||||
use labrinth::queue::payouts;
|
||||
use rust_decimal::{prelude::ToPrimitive, Decimal};
|
||||
|
||||
@@ -4,6 +4,7 @@ use common::api_v3::ApiV3;
|
||||
use common::database::*;
|
||||
use common::dummy_data::DUMMY_CATEGORIES;
|
||||
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use common::environment::{
|
||||
with_test_environment, with_test_environment_all, TestEnvironment,
|
||||
};
|
||||
@@ -12,7 +13,6 @@ use futures::StreamExt;
|
||||
use labrinth::database::models::project_item::{
|
||||
PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE,
|
||||
};
|
||||
use labrinth::models::ids::base62_impl::parse_base62;
|
||||
use labrinth::models::projects::ProjectId;
|
||||
use labrinth::models::teams::ProjectPermissions;
|
||||
use labrinth::util::actix::{MultipartSegment, MultipartSegmentData};
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::common::dummy_data::{
|
||||
};
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::test;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::{Duration, Utc};
|
||||
use common::api_common::models::CommonItemType;
|
||||
use common::api_common::Api;
|
||||
@@ -18,7 +19,6 @@ use common::environment::{
|
||||
with_test_environment, with_test_environment_all, TestEnvironment,
|
||||
};
|
||||
use common::{database::*, scopes::ScopeTest};
|
||||
use labrinth::models::ids::base62_impl::parse_base62;
|
||||
use labrinth::models::pats::Scopes;
|
||||
use labrinth::models::projects::ProjectId;
|
||||
use labrinth::models::users::UserId;
|
||||
|
||||
@@ -4,11 +4,11 @@ use common::database::*;
|
||||
|
||||
use common::dummy_data::DUMMY_CATEGORIES;
|
||||
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use common::environment::with_test_environment;
|
||||
use common::environment::TestEnvironment;
|
||||
use common::search::setup_search_projects;
|
||||
use futures::stream::StreamExt;
|
||||
use labrinth::models::ids::base62_impl::parse_base62;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::api_common::Api;
|
||||
|
||||
@@ -16,14 +16,12 @@ use crate::{
|
||||
};
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::test;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use futures::StreamExt;
|
||||
use itertools::Itertools;
|
||||
use labrinth::{
|
||||
database::models::project_item::PROJECTS_SLUGS_NAMESPACE,
|
||||
models::{
|
||||
ids::base62_impl::parse_base62, projects::ProjectId,
|
||||
teams::ProjectPermissions,
|
||||
},
|
||||
models::{projects::ProjectId, teams::ProjectPermissions},
|
||||
util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::common::dummy_data::TestFile;
|
||||
use crate::common::environment::with_test_environment;
|
||||
use crate::common::environment::TestEnvironment;
|
||||
use crate::common::scopes::ScopeTest;
|
||||
use labrinth::models::ids::base62_impl::parse_base62;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use labrinth::models::pats::Scopes;
|
||||
use labrinth::models::projects::ProjectId;
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::common::dummy_data::DUMMY_CATEGORIES;
|
||||
use crate::common::environment::with_test_environment;
|
||||
use crate::common::environment::TestEnvironment;
|
||||
use actix_http::StatusCode;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use futures::stream::StreamExt;
|
||||
use labrinth::models::ids::base62_impl::parse_base62;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -8,13 +8,13 @@ use crate::common::dummy_data::{
|
||||
use crate::common::get_json_val_str;
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::test;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use common::api_v3::ApiV3;
|
||||
use common::asserts::assert_common_version_ids;
|
||||
use common::database::USER_USER_PAT;
|
||||
use common::environment::{with_test_environment, with_test_environment_all};
|
||||
use futures::StreamExt;
|
||||
use labrinth::database::models::version_item::VERSIONS_NAMESPACE;
|
||||
use labrinth::models::ids::base62_impl::parse_base62;
|
||||
use labrinth::models::projects::{
|
||||
Dependency, DependencyType, VersionId, VersionStatus, VersionType,
|
||||
};
|
||||
|
||||
2
packages/app-lib/.cargo/config.toml
Normal file
2
packages/app-lib/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
SQLX_OFFLINE = "true"
|
||||
@@ -29,6 +29,7 @@ regex = "1.5"
|
||||
sys-info = "0.9.0"
|
||||
sysinfo = "0.30.8"
|
||||
thiserror = "1.0"
|
||||
either = "1.13"
|
||||
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["chrono", "env-filter"] }
|
||||
@@ -62,6 +63,8 @@ base64 = "0.22.0"
|
||||
|
||||
sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros" ] }
|
||||
|
||||
ariadne = { path = "../ariadne" }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52.0"
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::state::{FriendsSocket, UserFriend, UserStatus};
|
||||
use crate::state::{FriendsSocket, UserFriend};
|
||||
use ariadne::users::UserStatus;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn friends() -> crate::Result<Vec<UserFriend>> {
|
||||
|
||||
@@ -20,8 +20,9 @@ pub mod data {
|
||||
Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader,
|
||||
ModrinthCredentials, Organization, ProcessMetadata, ProfileFile,
|
||||
Project, ProjectType, SearchResult, SearchResults, Settings,
|
||||
TeamMember, Theme, User, UserFriend, UserStatus, Version, WindowSize,
|
||||
TeamMember, Theme, User, UserFriend, Version, WindowSize,
|
||||
};
|
||||
pub use ariadne::users::UserStatus;
|
||||
}
|
||||
|
||||
pub mod prelude {
|
||||
|
||||
@@ -13,6 +13,11 @@ pub enum ErrorKind {
|
||||
#[error("Serialization error (JSON): {0}")]
|
||||
JSONError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Serialization error (websocket): {0}")]
|
||||
WebsocketSerializationError(
|
||||
#[from] ariadne::networking::serialization::SerializationError,
|
||||
),
|
||||
|
||||
#[error("Error parsing UUID: {0}")]
|
||||
UUIDError(#[from] uuid::Error),
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Theseus state management system
|
||||
use crate::state::UserStatus;
|
||||
use ariadne::users::{UserId, UserStatus};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
@@ -262,8 +262,8 @@ pub enum EventError {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[serde(tag = "event")]
|
||||
pub enum FriendPayload {
|
||||
FriendRequest { from: String },
|
||||
UserOffline { id: String },
|
||||
FriendRequest { from: UserId },
|
||||
UserOffline { id: UserId },
|
||||
StatusUpdate { user_status: UserStatus },
|
||||
StatusSync,
|
||||
}
|
||||
|
||||
@@ -2,28 +2,42 @@ use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL};
|
||||
use crate::data::ModrinthCredentials;
|
||||
use crate::event::emit::emit_friend;
|
||||
use crate::event::FriendPayload;
|
||||
use crate::state::{ProcessManager, Profile};
|
||||
use crate::state::tunnel::InternalTunnelSocket;
|
||||
use crate::state::{ProcessManager, Profile, TunnelSocket};
|
||||
use crate::util::fetch::{fetch_advanced, fetch_json, FetchSemaphore};
|
||||
use ariadne::networking::message::{
|
||||
ClientToServerMessage, ServerToClientMessage,
|
||||
};
|
||||
use ariadne::users::{UserId, UserStatus};
|
||||
use async_tungstenite::tokio::{connect_async, ConnectStream};
|
||||
use async_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use async_tungstenite::tungstenite::Message;
|
||||
use async_tungstenite::WebSocketStream;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use either::Either;
|
||||
use futures::stream::SplitSink;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use reqwest::header::HeaderValue;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::tcp::OwnedReadHalf;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
type WriteSocket =
|
||||
pub(super) type WriteSocket =
|
||||
Arc<RwLock<Option<SplitSink<WebSocketStream<ConnectStream>, Message>>>>;
|
||||
pub(super) type TunnelSockets = Arc<DashMap<Uuid, Arc<InternalTunnelSocket>>>;
|
||||
|
||||
pub struct FriendsSocket {
|
||||
write: WriteSocket,
|
||||
user_statuses: Arc<DashMap<String, UserStatus>>,
|
||||
user_statuses: Arc<DashMap<UserId, UserStatus>>,
|
||||
tunnel_sockets: TunnelSockets,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
@@ -34,28 +48,6 @@ pub struct UserFriend {
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientToServerMessage {
|
||||
StatusUpdate { profile_name: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerToClientMessage {
|
||||
StatusUpdate { status: UserStatus },
|
||||
UserOffline { id: String },
|
||||
FriendStatuses { statuses: Vec<UserStatus> },
|
||||
FriendRequest { from: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct UserStatus {
|
||||
pub user_id: String,
|
||||
pub profile_name: Option<String>,
|
||||
pub last_update: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for FriendsSocket {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -67,6 +59,7 @@ impl FriendsSocket {
|
||||
Self {
|
||||
write: Arc::new(RwLock::new(None)),
|
||||
user_statuses: Arc::new(DashMap::new()),
|
||||
tunnel_sockets: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +113,7 @@ impl FriendsSocket {
|
||||
|
||||
let write_handle = self.write.clone();
|
||||
let statuses = self.user_statuses.clone();
|
||||
let sockets = self.tunnel_sockets.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut read_stream = read;
|
||||
@@ -128,18 +122,14 @@ impl FriendsSocket {
|
||||
Ok(msg) => {
|
||||
let server_message = match msg {
|
||||
Message::Text(text) => {
|
||||
serde_json::from_str::<
|
||||
ServerToClientMessage,
|
||||
>(
|
||||
&text
|
||||
ServerToClientMessage::deserialize(
|
||||
Either::Left(&text),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
Message::Binary(bytes) => {
|
||||
serde_json::from_slice::<
|
||||
ServerToClientMessage,
|
||||
>(
|
||||
&bytes
|
||||
ServerToClientMessage::deserialize(
|
||||
Either::Right(&bytes),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
@@ -165,7 +155,7 @@ impl FriendsSocket {
|
||||
{
|
||||
match server_message {
|
||||
ServerToClientMessage::StatusUpdate { status } => {
|
||||
statuses.insert(status.user_id.clone(), status.clone());
|
||||
statuses.insert(status.user_id, status.clone());
|
||||
let _ = emit_friend(FriendPayload::StatusUpdate { user_status: status }).await;
|
||||
},
|
||||
ServerToClientMessage::UserOffline { id } => {
|
||||
@@ -175,13 +165,41 @@ impl FriendsSocket {
|
||||
ServerToClientMessage::FriendStatuses { statuses: new_statuses } => {
|
||||
statuses.clear();
|
||||
new_statuses.into_iter().for_each(|status| {
|
||||
statuses.insert(status.user_id.clone(), status);
|
||||
statuses.insert(status.user_id, status);
|
||||
});
|
||||
let _ = emit_friend(FriendPayload::StatusSync).await;
|
||||
}
|
||||
ServerToClientMessage::FriendRequest { from } => {
|
||||
let _ = emit_friend(FriendPayload::FriendRequest { from }).await;
|
||||
}
|
||||
ServerToClientMessage::FriendRequestRejected { .. } => todo!(),
|
||||
|
||||
ServerToClientMessage::FriendSocketListening { .. } => {}, // TODO
|
||||
ServerToClientMessage::FriendSocketStoppedListening { .. } => {}, // TODO
|
||||
|
||||
ServerToClientMessage::SocketConnected { to_socket, new_socket } => {
|
||||
if let Some(connected_to) = sockets.get(&to_socket) {
|
||||
if let InternalTunnelSocket::Listening(local_addr) = *connected_to.value().clone() {
|
||||
if let Ok(new_stream) = TcpStream::connect(local_addr).await {
|
||||
let (read, write) = new_stream.into_split();
|
||||
sockets.insert(new_socket, Arc::new(InternalTunnelSocket::Connected(Mutex::new(write))));
|
||||
Self::socket_read_loop(write_handle.clone(), read, new_socket);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = Self::send_message(&write_handle, ClientToServerMessage::SocketClose { socket: new_socket }).await;
|
||||
},
|
||||
ServerToClientMessage::SocketClosed { socket } => {
|
||||
sockets.remove_if(&socket, |_, x| matches!(*x.clone(), InternalTunnelSocket::Connected(_)));
|
||||
},
|
||||
ServerToClientMessage::SocketData { socket, data } => {
|
||||
if let Some(mut socket) = sockets.get_mut(&socket) {
|
||||
if let InternalTunnelSocket::Connected(ref stream) = *socket.value_mut().clone() {
|
||||
let _ = stream.lock().await.write_all(&data).await;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,10 +235,7 @@ impl FriendsSocket {
|
||||
let mut last_ping = Utc::now();
|
||||
|
||||
loop {
|
||||
let connected = {
|
||||
let read = state.friends_socket.write.read().await;
|
||||
read.is_some()
|
||||
};
|
||||
let connected = state.friends_socket.is_connected().await;
|
||||
|
||||
if !connected
|
||||
&& Utc::now().signed_duration_since(last_connection)
|
||||
@@ -269,16 +284,11 @@ impl FriendsSocket {
|
||||
&self,
|
||||
profile_name: Option<String>,
|
||||
) -> crate::Result<()> {
|
||||
let mut write_lock = self.write.write().await;
|
||||
if let Some(ref mut write_half) = *write_lock {
|
||||
write_half
|
||||
.send(Message::Text(serde_json::to_string(
|
||||
&ClientToServerMessage::StatusUpdate { profile_name },
|
||||
)?))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Self::send_message(
|
||||
&self.write,
|
||||
ClientToServerMessage::StatusUpdate { profile_name },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
@@ -346,4 +356,81 @@ impl FriendsSocket {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn open_port(&self, port: u16) -> crate::Result<TunnelSocket> {
|
||||
let socket_id = Uuid::new_v4();
|
||||
let socket = self.tunnel_sockets.entry(socket_id).insert(Arc::new(
|
||||
InternalTunnelSocket::Listening(SocketAddr::new(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
port,
|
||||
)),
|
||||
));
|
||||
Self::send_message(
|
||||
&self.write,
|
||||
ClientToServerMessage::SocketListen { socket: socket_id },
|
||||
)
|
||||
.await?;
|
||||
self.create_tunnel_socket(socket_id, socket)
|
||||
}
|
||||
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.write.read().await.is_some()
|
||||
}
|
||||
|
||||
fn create_tunnel_socket(
|
||||
&self,
|
||||
socket_id: Uuid,
|
||||
socket: impl Deref<Target = Arc<InternalTunnelSocket>>,
|
||||
) -> crate::Result<TunnelSocket> {
|
||||
Ok(TunnelSocket {
|
||||
socket_id,
|
||||
write: self.write.clone(),
|
||||
sockets: self.tunnel_sockets.clone(),
|
||||
internal: socket.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn socket_read_loop(
|
||||
write: WriteSocket,
|
||||
mut read_half: OwnedReadHalf,
|
||||
socket_id: Uuid,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut read_buffer = [0u8; 8192];
|
||||
loop {
|
||||
match read_half.read(&mut read_buffer).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
let _ = Self::send_message(
|
||||
&write,
|
||||
ClientToServerMessage::SocketSend {
|
||||
socket: socket_id,
|
||||
data: read_buffer[..n].to_vec(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(write))]
|
||||
pub(super) async fn send_message(
|
||||
write: &WriteSocket,
|
||||
message: ClientToServerMessage,
|
||||
) -> crate::Result<()> {
|
||||
let serialized = match message.serialize()? {
|
||||
Either::Left(text) => Message::text(text),
|
||||
Either::Right(bytes) => Message::binary(bytes),
|
||||
};
|
||||
|
||||
let mut write_lock = write.write().await;
|
||||
if let Some(ref mut write_half) = *write_lock {
|
||||
write_half.send(serialized).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ pub use self::cache::*;
|
||||
mod friends;
|
||||
pub use self::friends::*;
|
||||
|
||||
mod tunnel;
|
||||
pub use self::tunnel::*;
|
||||
|
||||
pub mod db;
|
||||
pub mod fs_watcher;
|
||||
mod mr_auth;
|
||||
|
||||
61
packages/app-lib/src/state/tunnel.rs
Normal file
61
packages/app-lib/src/state/tunnel.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use crate::state::friends::{TunnelSockets, WriteSocket};
|
||||
use crate::state::FriendsSocket;
|
||||
use ariadne::networking::message::ClientToServerMessage;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::tcp::OwnedWriteHalf;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) enum InternalTunnelSocket {
|
||||
Listening(SocketAddr),
|
||||
Connected(Mutex<OwnedWriteHalf>),
|
||||
}
|
||||
|
||||
pub struct TunnelSocket {
|
||||
pub(super) socket_id: Uuid,
|
||||
pub(super) write: WriteSocket,
|
||||
pub(super) sockets: TunnelSockets,
|
||||
pub(super) internal: Arc<InternalTunnelSocket>,
|
||||
}
|
||||
|
||||
impl TunnelSocket {
|
||||
pub fn socket_id(&self) -> Uuid {
|
||||
self.socket_id
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) -> crate::Result<()> {
|
||||
if self.sockets.remove(&self.socket_id).is_some() {
|
||||
FriendsSocket::send_message(
|
||||
&self.write,
|
||||
ClientToServerMessage::SocketClose {
|
||||
socket: self.socket_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let InternalTunnelSocket::Connected(ref stream) =
|
||||
*self.internal.clone()
|
||||
{
|
||||
stream.lock().await.shutdown().await?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TunnelSocket {
|
||||
fn drop(&mut self) {
|
||||
if self.sockets.remove(&self.socket_id).is_some() {
|
||||
let write = self.write.clone();
|
||||
let socket_id = self.socket_id;
|
||||
tokio::spawn(async move {
|
||||
let _ = FriendsSocket::send_message(
|
||||
&write,
|
||||
ClientToServerMessage::SocketClose { socket: socket_id },
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Functions for fetching infromation from the Internet
|
||||
use super::io::{self, IOError};
|
||||
use crate::config::{MODRINTH_API_URL, MODRINTH_API_URL_V3};
|
||||
use crate::event::emit::emit_loading;
|
||||
use crate::event::LoadingBarId;
|
||||
use bytes::Bytes;
|
||||
@@ -11,8 +13,6 @@ use std::time::{self};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::{fs::File, io::AsyncWriteExt};
|
||||
|
||||
use super::io::{self, IOError};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct IoSemaphore(pub Semaphore);
|
||||
#[derive(Debug)]
|
||||
@@ -87,7 +87,8 @@ pub async fn fetch_advanced(
|
||||
.map(|x| &*x.0.to_lowercase() == "authorization")
|
||||
.unwrap_or(false)
|
||||
&& (url.starts_with("https://cdn.modrinth.com")
|
||||
|| url.starts_with("https://api.modrinth.com"))
|
||||
|| url.starts_with(MODRINTH_API_URL)
|
||||
|| url.starts_with(MODRINTH_API_URL_V3))
|
||||
{
|
||||
crate::state::ModrinthCredentials::get_active(exec).await?
|
||||
} else {
|
||||
|
||||
15
packages/ariadne/Cargo.toml
Normal file
15
packages/ariadne/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ariadne"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "1.0"
|
||||
uuid = { version = "1.2.2", features = ["v4", "fast-rng", "serde"] }
|
||||
serde_bytes = "0.11"
|
||||
rand = "0.8.5"
|
||||
either = "1.13"
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
serde_cbor = "0.11"
|
||||
216
packages/ariadne/src/ids.rs
Normal file
216
packages/ariadne/src/ids.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
pub use super::users::UserId;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Generates a random 64 bit integer that is exactly `n` characters
|
||||
/// long when encoded as base62.
|
||||
///
|
||||
/// Uses `rand`'s thread rng on every call.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This method panics if `n` is 0 or greater than 11, since a `u64`
|
||||
/// can only represent up to 11 character base62 strings
|
||||
#[inline]
|
||||
pub fn random_base62(n: usize) -> u64 {
|
||||
random_base62_rng(&mut rand::thread_rng(), n)
|
||||
}
|
||||
|
||||
/// Generates a random 64 bit integer that is exactly `n` characters
|
||||
/// long when encoded as base62, using the given rng.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This method panics if `n` is 0 or greater than 11, since a `u64`
|
||||
/// can only represent up to 11 character base62 strings
|
||||
pub fn random_base62_rng<R: rand::RngCore>(rng: &mut R, n: usize) -> u64 {
|
||||
random_base62_rng_range(rng, n, n)
|
||||
}
|
||||
|
||||
pub fn random_base62_rng_range<R: rand::RngCore>(
|
||||
rng: &mut R,
|
||||
n_min: usize,
|
||||
n_max: usize,
|
||||
) -> u64 {
|
||||
use rand::Rng;
|
||||
assert!(n_min > 0 && n_max <= 11 && n_min <= n_max);
|
||||
// gen_range is [low, high): max value is `MULTIPLES[n] - 1`,
|
||||
// which is n characters long when encoded
|
||||
rng.gen_range(MULTIPLES[n_min - 1]..MULTIPLES[n_max])
|
||||
}
|
||||
|
||||
const MULTIPLES: [u64; 12] = [
|
||||
1,
|
||||
62,
|
||||
62 * 62,
|
||||
62 * 62 * 62,
|
||||
62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62,
|
||||
62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62,
|
||||
u64::MAX,
|
||||
];
|
||||
|
||||
/// An ID encoded as base62 for use in the API.
|
||||
///
|
||||
/// All ids should be random and encode to 8-10 character base62 strings,
|
||||
/// to avoid enumeration and other attacks.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub struct Base62Id(pub u64);
|
||||
|
||||
/// An error decoding a number from base62.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DecodingError {
|
||||
/// Encountered a non-base62 character in a base62 string
|
||||
#[error("Invalid character {0:?} in base62 encoding")]
|
||||
InvalidBase62(char),
|
||||
/// Encountered integer overflow when decoding a base62 id.
|
||||
#[error("Base62 decoding overflowed")]
|
||||
Overflow,
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! from_base62id {
|
||||
($($struct:ty, $con:expr;)+) => {
|
||||
$(
|
||||
impl From<Base62Id> for $struct {
|
||||
fn from(id: Base62Id) -> $struct {
|
||||
$con(id.0)
|
||||
}
|
||||
}
|
||||
impl From<$struct> for Base62Id {
|
||||
fn from(id: $struct) -> Base62Id {
|
||||
Base62Id(id.0)
|
||||
}
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! impl_base62_display {
|
||||
($struct:ty) => {
|
||||
impl std::fmt::Display for $struct {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&$crate::ids::base62_impl::to_base62(self.0))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
impl_base62_display!(Base62Id);
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! base62_id_impl {
|
||||
($struct:ty, $cons:expr) => {
|
||||
$crate::ids::from_base62id!($struct, $cons;);
|
||||
$crate::ids::impl_base62_display!($struct);
|
||||
}
|
||||
}
|
||||
base62_id_impl!(UserId, UserId);
|
||||
|
||||
pub use {base62_id_impl, from_base62id, impl_base62_display};
|
||||
|
||||
pub mod base62_impl {
|
||||
use serde::de::{self, Deserializer, Visitor};
|
||||
use serde::ser::Serializer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Base62Id, DecodingError};
|
||||
|
||||
impl<'de> Deserialize<'de> for Base62Id {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct Base62Visitor;
|
||||
|
||||
impl Visitor<'_> for Base62Visitor {
|
||||
type Value = Base62Id;
|
||||
|
||||
fn expecting(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter,
|
||||
) -> std::fmt::Result {
|
||||
formatter.write_str("a base62 string id")
|
||||
}
|
||||
|
||||
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
Ok(Base62Id(v))
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, string: &str) -> Result<Base62Id, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
parse_base62(string).map(Base62Id).map_err(E::custom)
|
||||
}
|
||||
}
|
||||
|
||||
if deserializer.is_human_readable() {
|
||||
deserializer.deserialize_str(Base62Visitor)
|
||||
} else {
|
||||
deserializer.deserialize_u64(Base62Visitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Base62Id {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
if serializer.is_human_readable() {
|
||||
serializer.serialize_str(&to_base62(self.0))
|
||||
} else {
|
||||
serializer.serialize_u64(self.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BASE62_CHARS: [u8; 62] =
|
||||
*b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
pub fn to_base62(mut num: u64) -> String {
|
||||
let length = (num as f64).log(62.0).ceil() as usize;
|
||||
let mut output = String::with_capacity(length);
|
||||
|
||||
while num > 0 {
|
||||
// Could be done more efficiently, but requires byte
|
||||
// manipulation of strings & Vec<u8> -> String conversion
|
||||
output.insert(0, BASE62_CHARS[(num % 62) as usize] as char);
|
||||
num /= 62;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn parse_base62(string: &str) -> Result<u64, DecodingError> {
|
||||
let mut num: u64 = 0;
|
||||
for c in string.chars() {
|
||||
let next_digit;
|
||||
if c.is_ascii_digit() {
|
||||
next_digit = (c as u8 - b'0') as u64;
|
||||
} else if c.is_ascii_uppercase() {
|
||||
next_digit = 10 + (c as u8 - b'A') as u64;
|
||||
} else if c.is_ascii_lowercase() {
|
||||
next_digit = 36 + (c as u8 - b'a') as u64;
|
||||
} else {
|
||||
return Err(DecodingError::InvalidBase62(c));
|
||||
}
|
||||
|
||||
// We don't want this panicking or wrapping on integer overflow
|
||||
if let Some(n) =
|
||||
num.checked_mul(62).and_then(|n| n.checked_add(next_digit))
|
||||
{
|
||||
num = n;
|
||||
} else {
|
||||
return Err(DecodingError::Overflow);
|
||||
}
|
||||
}
|
||||
Ok(num)
|
||||
}
|
||||
}
|
||||
3
packages/ariadne/src/lib.rs
Normal file
3
packages/ariadne/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod ids;
|
||||
pub mod networking;
|
||||
pub mod users;
|
||||
3
packages/ariadne/src/mod.rs
Normal file
3
packages/ariadne/src/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod ids;
|
||||
pub mod networking;
|
||||
pub mod users;
|
||||
65
packages/ariadne/src/networking/message.rs
Normal file
65
packages/ariadne/src/networking/message.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use crate::ids::UserId;
|
||||
use crate::users::UserStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientToServerMessage {
|
||||
StatusUpdate {
|
||||
profile_name: Option<String>,
|
||||
},
|
||||
|
||||
SocketListen {
|
||||
socket: Uuid,
|
||||
},
|
||||
SocketClose {
|
||||
socket: Uuid,
|
||||
},
|
||||
SocketSend {
|
||||
socket: Uuid,
|
||||
#[serde(with = "serde_bytes")]
|
||||
data: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerToClientMessage {
|
||||
StatusUpdate {
|
||||
status: UserStatus,
|
||||
},
|
||||
UserOffline {
|
||||
id: UserId,
|
||||
},
|
||||
FriendStatuses {
|
||||
statuses: Vec<UserStatus>,
|
||||
},
|
||||
FriendRequest {
|
||||
from: UserId,
|
||||
},
|
||||
FriendRequestRejected {
|
||||
from: UserId,
|
||||
},
|
||||
|
||||
FriendSocketListening {
|
||||
user: UserId,
|
||||
socket: Uuid,
|
||||
},
|
||||
FriendSocketStoppedListening {
|
||||
user: UserId,
|
||||
},
|
||||
|
||||
SocketConnected {
|
||||
to_socket: Uuid,
|
||||
new_socket: Uuid,
|
||||
},
|
||||
SocketClosed {
|
||||
socket: Uuid,
|
||||
},
|
||||
SocketData {
|
||||
socket: Uuid,
|
||||
#[serde(with = "serde_bytes")]
|
||||
data: Vec<u8>,
|
||||
},
|
||||
}
|
||||
2
packages/ariadne/src/networking/mod.rs
Normal file
2
packages/ariadne/src/networking/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod message;
|
||||
pub mod serialization;
|
||||
56
packages/ariadne/src/networking/serialization.rs
Normal file
56
packages/ariadne/src/networking/serialization.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use super::message::{ClientToServerMessage, ServerToClientMessage};
|
||||
use either::Either;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SerializationError {
|
||||
#[error("Failed to (de)serialize message: {0}")]
|
||||
SerializationFailed(#[from] serde_json::Error),
|
||||
|
||||
#[error("Failed to (de)serialize binary message: {0}")]
|
||||
BinarySerializationFailed(#[from] serde_cbor::Error),
|
||||
}
|
||||
|
||||
macro_rules! message_serialization {
|
||||
($message_enum:ty $(,$binary_pattern:pat_param)* $(,)?) => {
|
||||
impl $message_enum {
|
||||
pub fn is_binary(&self) -> bool {
|
||||
match self {
|
||||
$(
|
||||
$binary_pattern => true,
|
||||
)*
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize(
|
||||
&self,
|
||||
) -> Result<Either<String, Vec<u8>>, SerializationError> {
|
||||
Ok(match self {
|
||||
$(
|
||||
$binary_pattern => Either::Right(serde_cbor::to_vec(self)?),
|
||||
)*
|
||||
_ => Either::Left(serde_json::to_string(self)?),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn deserialize(
|
||||
msg: Either<&str, &[u8]>,
|
||||
) -> Result<Self, SerializationError> {
|
||||
Ok(match msg {
|
||||
Either::Left(text) => serde_json::from_str(&text)?,
|
||||
Either::Right(bytes) => serde_cbor::from_slice(&bytes)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
message_serialization!(
|
||||
ClientToServerMessage,
|
||||
ClientToServerMessage::SocketSend { .. },
|
||||
);
|
||||
message_serialization!(
|
||||
ServerToClientMessage,
|
||||
ServerToClientMessage::SocketData { .. },
|
||||
);
|
||||
15
packages/ariadne/src/users.rs
Normal file
15
packages/ariadne/src/users.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use super::ids::Base62Id;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct UserId(pub u64);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserStatus {
|
||||
pub user_id: UserId,
|
||||
pub profile_name: Option<String>,
|
||||
pub last_update: DateTime<Utc>,
|
||||
}
|
||||
@@ -37,7 +37,7 @@ async function copyText() {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
width: min-content;
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
@@ -50,12 +50,6 @@ async function copyText() {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
span {
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
57
packages/ui/src/components/base/Timeline.vue
Normal file
57
packages/ui/src/components/base/Timeline.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
fadeOutStart?: boolean
|
||||
fadeOutEnd?: boolean
|
||||
}>(),
|
||||
{
|
||||
fadeOutStart: false,
|
||||
fadeOutEnd: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="relative flex flex-col gap-4 pb-6 isolate">
|
||||
<div class="absolute flex h-full w-4 justify-center">
|
||||
<div
|
||||
class="timeline-indicator"
|
||||
:class="{ 'fade-out-start': fadeOutStart, 'fade-out-end': fadeOutEnd }"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.timeline-indicator {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--timeline-line-color, var(--color-raised-bg)) 66%,
|
||||
rgba(255, 255, 255, 0) 0%
|
||||
);
|
||||
background-size: 100% 30px;
|
||||
background-repeat: repeat-y;
|
||||
margin-top: 1rem;
|
||||
|
||||
height: calc(100% - 1rem);
|
||||
width: 4px;
|
||||
z-index: -1;
|
||||
|
||||
&.fade-out-start {
|
||||
mask-image: linear-gradient(to top, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
|
||||
&.fade-out-end {
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
|
||||
&.fade-out-start.fade-out-end {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black 8rem,
|
||||
black calc(100% - 8rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -35,6 +35,7 @@ export { default as Slider } from './base/Slider.vue'
|
||||
export { default as StatItem } from './base/StatItem.vue'
|
||||
export { default as TagItem } from './base/TagItem.vue'
|
||||
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'
|
||||
export { default as Timeline } from './base/Timeline.vue'
|
||||
export { default as Toggle } from './base/Toggle.vue'
|
||||
|
||||
// Branding
|
||||
|
||||
@@ -67,7 +67,7 @@ function show() {
|
||||
}
|
||||
|
||||
function hide() {
|
||||
props.onHide()
|
||||
props.onHide?.()
|
||||
actuallyShown.value = false
|
||||
setTimeout(() => {
|
||||
shown.value = false
|
||||
|
||||
@@ -90,7 +90,7 @@ function addBodyPadding() {
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
props.onShow()
|
||||
props.onShow?.()
|
||||
open.value = true
|
||||
|
||||
addBodyPadding()
|
||||
@@ -109,7 +109,7 @@ function show(event?: MouseEvent) {
|
||||
}
|
||||
|
||||
function hide() {
|
||||
props.onHide()
|
||||
props.onHide?.()
|
||||
visible.value = false
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.paddingRight = ''
|
||||
|
||||
@@ -10,6 +10,35 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2025-03-05T17:40:00-08:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Fixed moderation-end pages failing under edge cases.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-05T12:40:00-08:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Fixed various errors with modals for some users.
|
||||
- Fixed hold R button not working on some systems.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-03T22:30:00-08:00`,
|
||||
product: 'web',
|
||||
body: `### Added
|
||||
- Hold R for a random project :D
|
||||
|
||||
### Improvements
|
||||
- Improved admin navigation and admin panels.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-02T18:45:00-08:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Added option to copy version IDs from the version list for project members and developer mode.
|
||||
- Fixed the staff moderation checklist going off the screen.`,
|
||||
},
|
||||
{
|
||||
date: `2025-02-25T10:20:00-08:00`,
|
||||
product: 'servers',
|
||||
|
||||
2260
pnpm-lock.yaml
generated
2260
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user