fix: CI workflow, password reset flow, health/limits in services, session refactor, dead mixin stub cleanup
This commit is contained in:
parent
45dd592c40
commit
f4e15b45c9
95 changed files with 899 additions and 185 deletions
132
.github/workflows/ci.yml
vendored
Normal file
132
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: backend (cargo test + clippy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: lovisual
|
||||||
|
POSTGRES_PASSWORD: lovisual
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U lovisual"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
s3:
|
||||||
|
image: minio/minio:latest
|
||||||
|
env:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- 9000:9000
|
||||||
|
options: >-
|
||||||
|
--health-cmd "mc ready local || curl -sf http://localhost:9000/minio/health/live"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgres://lovisual:lovisual@localhost:5432/accounts_db
|
||||||
|
CONFIGS_DATABASE_URL: postgres://lovisual:lovisual@localhost:5432/configs_db
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install protoc (prost-build requirement)
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
|
- name: Cache cargo build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: backend
|
||||||
|
|
||||||
|
- name: Create service databases
|
||||||
|
run: |
|
||||||
|
psql -h localhost -U lovisual -d postgres -c "CREATE DATABASE accounts_db"
|
||||||
|
psql -h localhost -U lovisual -d postgres -c "CREATE DATABASE configs_db"
|
||||||
|
env:
|
||||||
|
PGPASSWORD: lovisual
|
||||||
|
|
||||||
|
- name: cargo fmt --check
|
||||||
|
run: cargo fmt --check
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --workspace --no-fail-fast
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: frontend (lint + test + build)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Cache bun install
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: bun-${{ runner.os }}-${{ hashFiles('frontend/bun.lock') }}
|
||||||
|
restore-keys: bun-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: lint
|
||||||
|
run: bun run lint
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: bun run test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: bun run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
mod:
|
||||||
|
name: mod (gradle test + checkFolderLimit)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install JDK 25
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 25
|
||||||
|
|
||||||
|
- name: Setup Gradle (dependency + loom caching)
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: test + checkFolderLimit
|
||||||
|
run: ./gradlew test checkFolderLimit --console=plain
|
||||||
|
working-directory: mod
|
||||||
|
|
@ -21,6 +21,10 @@ ACCOUNTS_GRPC_URL=http://127.0.0.1:50051
|
||||||
CONFIGS_HTTP_URL=http://127.0.0.1:8082
|
CONFIGS_HTTP_URL=http://127.0.0.1:8082
|
||||||
CHAT_HTTP_URL=http://127.0.0.1:8083
|
CHAT_HTTP_URL=http://127.0.0.1:8083
|
||||||
SITE_ORIGIN=http://localhost:5173
|
SITE_ORIGIN=http://localhost:5173
|
||||||
|
# true ONLY when the gateway is behind a reverse proxy that sets
|
||||||
|
# X-Forwarded-For (nginx in deploy/): per-IP rate limits then key on the
|
||||||
|
# real client. docker-compose.prod.yml overrides this to "true" itself;
|
||||||
|
# direct exposure must keep it false, or clients could spoof the header.
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
# Directory the gateway serves under GET /downloads/* (lovisual.jar lives here;
|
# Directory the gateway serves under GET /downloads/* (lovisual.jar lives here;
|
||||||
# docker-compose.prod.yml mounts it as /srv/downloads and sets the variable itself)
|
# docker-compose.prod.yml mounts it as /srv/downloads and sets the variable itself)
|
||||||
|
|
|
||||||
15
backend/Cargo.lock
generated
15
backend/Cargo.lock
generated
|
|
@ -872,6 +872,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower-http 0.7.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|
@ -941,6 +942,7 @@ dependencies = [
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tonic",
|
"tonic",
|
||||||
|
"tower-http 0.7.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|
@ -2330,6 +2332,15 @@ dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
|
|
@ -4174,10 +4185,14 @@ version = "0.3.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.8", features = ["multipart", "macros"] }
|
axum = { version = "0.8", features = ["multipart", "macros"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||||
tower-http = { version = "0.7", features = ["trace", "cors"] }
|
tower-http = { version = "0.7", features = ["trace", "cors"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "macros", "migrate"] }
|
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "macros", "migrate"] }
|
||||||
|
|
@ -36,4 +36,5 @@ tonic = "0.14"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
axum-test = "21"
|
axum-test = "21"
|
||||||
|
aws-sdk-s3 = "1"
|
||||||
tokio-stream = { version = "0.1", features = ["net"] }
|
tokio-stream = { version = "0.1", features = ["net"] }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Single-use password reset tokens (stored hashed, like refresh tokens).
|
||||||
|
-- A token is valid for 30 minutes; issuing a new request for the same
|
||||||
|
-- account supersedes any token still pending from an earlier request.
|
||||||
|
CREATE TABLE password_reset_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_password_reset_tokens_account_id ON password_reset_tokens(account_id);
|
||||||
|
|
@ -24,6 +24,17 @@ pub fn validate_register(
|
||||||
password: &str,
|
password: &str,
|
||||||
nick: &str,
|
nick: &str,
|
||||||
) -> Result<(String, String), AppError> {
|
) -> Result<(String, String), AppError> {
|
||||||
|
let email = normalize_email(email)?;
|
||||||
|
let nick = validate_nick(nick)?;
|
||||||
|
validate_password(password)?;
|
||||||
|
Ok((email, nick))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims and shape-checks an email address the same way for every auth flow
|
||||||
|
/// that accepts one (register, password reset request). The lookup itself is
|
||||||
|
/// case-insensitive: `accounts_email_lower_idx` is a unique index on
|
||||||
|
/// `lower(email)`, and `repo::find_by_email` lowercases the query value.
|
||||||
|
pub fn normalize_email(email: &str) -> Result<String, AppError> {
|
||||||
let email = email.trim();
|
let email = email.trim();
|
||||||
if email.is_empty() {
|
if email.is_empty() {
|
||||||
return Err(AppError::Validation("email must not be empty".into()));
|
return Err(AppError::Validation("email must not be empty".into()));
|
||||||
|
|
@ -42,7 +53,10 @@ pub fn validate_register(
|
||||||
"email must have exactly one '@' with non-empty parts".into(),
|
"email must have exactly one '@' with non-empty parts".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
Ok(email.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nick(nick: &str) -> Result<String, AppError> {
|
||||||
let nick = nick.trim();
|
let nick = nick.trim();
|
||||||
let nick_len = nick.chars().count();
|
let nick_len = nick.chars().count();
|
||||||
if nick_len == 0 || nick_len > 32 {
|
if nick_len == 0 || nick_len > 32 {
|
||||||
|
|
@ -55,7 +69,11 @@ pub fn validate_register(
|
||||||
"nick must not contain control characters".into(),
|
"nick must not contain control characters".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
Ok(nick.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Password policy shared by registration and password reset.
|
||||||
|
pub fn validate_password(password: &str) -> Result<(), AppError> {
|
||||||
if password.chars().count() < 8 {
|
if password.chars().count() < 8 {
|
||||||
return Err(AppError::Validation(
|
return Err(AppError::Validation(
|
||||||
"password must be at least 8 characters".into(),
|
"password must be at least 8 characters".into(),
|
||||||
|
|
@ -66,8 +84,7 @@ pub fn validate_register(
|
||||||
"password must be at most {MAX_PASSWORD_BYTES} bytes"
|
"password must be at most {MAX_PASSWORD_BYTES} bytes"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
Ok((email.to_string(), nick.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::accounts::model::validate_register;
|
use crate::accounts::model::validate_register;
|
||||||
use crate::accounts::repo;
|
use crate::accounts::repo;
|
||||||
use crate::auth::{password, tokens};
|
use crate::auth::{password, reset, tokens};
|
||||||
use crate::error::{AppError, AppJson};
|
use crate::error::{AppError, AppJson};
|
||||||
use axum::{Json, extract::State, http::StatusCode};
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
|
@ -13,6 +13,9 @@ pub struct AuthState {
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub cookie_secure: bool,
|
pub cookie_secure: bool,
|
||||||
pub hasher: password::PasswordHasher,
|
pub hasher: password::PasswordHasher,
|
||||||
|
/// Reset-email delivery back-end: Log in production (until a real
|
||||||
|
/// provider lands), Queue in tests.
|
||||||
|
pub mail: reset::MailBox,
|
||||||
// A real Argon2id hash of a throwaway string. `login` verifies against
|
// A real Argon2id hash of a throwaway string. `login` verifies against
|
||||||
// it when the email is unknown so that "no such account" costs the same
|
// it when the email is unknown so that "no such account" costs the same
|
||||||
// ~100ms as "wrong password" — otherwise response time leaks which
|
// ~100ms as "wrong password" — otherwise response time leaks which
|
||||||
|
|
@ -22,6 +25,15 @@ pub struct AuthState {
|
||||||
|
|
||||||
impl AuthState {
|
impl AuthState {
|
||||||
pub fn new(pool: sqlx::PgPool, jwt_secret: String, cookie_secure: bool) -> Self {
|
pub fn new(pool: sqlx::PgPool, jwt_secret: String, cookie_secure: bool) -> Self {
|
||||||
|
Self::with_mail(pool, jwt_secret, cookie_secure, reset::MailBox::Log)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_mail(
|
||||||
|
pool: sqlx::PgPool,
|
||||||
|
jwt_secret: String,
|
||||||
|
cookie_secure: bool,
|
||||||
|
mail: reset::MailBox,
|
||||||
|
) -> Self {
|
||||||
// Hashing a fixed, short constant with fixed valid params cannot
|
// Hashing a fixed, short constant with fixed valid params cannot
|
||||||
// fail; this is not user input, so the expect is a startup invariant.
|
// fail; this is not user input, so the expect is a startup invariant.
|
||||||
let dummy_hash = password::hash_password("timing-equalizer-not-a-real-password")
|
let dummy_hash = password::hash_password("timing-equalizer-not-a-real-password")
|
||||||
|
|
@ -31,6 +43,7 @@ impl AuthState {
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
cookie_secure,
|
cookie_secure,
|
||||||
hasher: password::PasswordHasher::new(),
|
hasher: password::PasswordHasher::new(),
|
||||||
|
mail,
|
||||||
dummy_hash,
|
dummy_hash,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
pub mod reset;
|
||||||
pub mod tokens;
|
pub mod tokens;
|
||||||
|
|
|
||||||
86
backend/accounts-service/src/auth/reset/handlers.rs
Normal file
86
backend/accounts-service/src/auth/reset/handlers.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use crate::accounts::{model, repo};
|
||||||
|
use crate::auth::reset;
|
||||||
|
use crate::error::{AppError, AppJson};
|
||||||
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::super::handlers::AuthState;
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ForgotPasswordRequest {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AcceptedResponse {
|
||||||
|
pub status: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /auth/forgot-password { email }
|
||||||
|
///
|
||||||
|
/// The response is identical whether or not the email is registered: no
|
||||||
|
/// oracle for account enumeration. Delivery happens out of band; the gateway
|
||||||
|
/// rate-limits this route per IP (3/hour) to keep the mailer from being
|
||||||
|
/// weaponised.
|
||||||
|
pub async fn forgot_password(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
AppJson(req): AppJson<ForgotPasswordRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AcceptedResponse>), AppError> {
|
||||||
|
let email = model::normalize_email(&req.email)?;
|
||||||
|
if let Some(account) = repo::find_by_email(&state.pool, &email).await? {
|
||||||
|
let token = reset::issue_reset_token(&state.pool, account.id).await?;
|
||||||
|
state.mail.send(&email, &token);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
Json(AcceptedResponse {
|
||||||
|
status: "reset email sent if the account exists",
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResetPasswordRequest {
|
||||||
|
pub token: String,
|
||||||
|
pub new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /auth/reset-password { token, new_password }
|
||||||
|
///
|
||||||
|
/// Consumes the token atomically, rewrites the password hash and revokes
|
||||||
|
/// every refresh session of the account. A bad token is a plain 400 with no
|
||||||
|
/// distinction between unknown, expired and already-used — all three are the
|
||||||
|
/// same "try again" situation from an attacker's point of view.
|
||||||
|
pub async fn reset_password(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
AppJson(req): AppJson<ResetPasswordRequest>,
|
||||||
|
) -> Result<Json<AcceptedResponse>, AppError> {
|
||||||
|
if !reset::is_well_formed_token(&req.token) {
|
||||||
|
return Err(AppError::Validation("malformed reset token".into()));
|
||||||
|
}
|
||||||
|
model::validate_password(&req.new_password)?;
|
||||||
|
|
||||||
|
let account_id = reset::consume_reset_token(&state.pool, &req.token)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Validation("reset token is invalid or expired".into()))?;
|
||||||
|
|
||||||
|
let hash = state
|
||||||
|
.hasher
|
||||||
|
.hash(req.new_password)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
let updated = sqlx::query("UPDATE accounts SET password_hash = $2 WHERE id = $1")
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(&hash)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if updated.rows_affected() != 1 {
|
||||||
|
// The token row referenced a cascade-deleted account.
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"reset token is invalid or expired".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
reset::revoke_all_sessions(&state.pool, account_id).await?;
|
||||||
|
Ok(Json(AcceptedResponse {
|
||||||
|
status: "password updated",
|
||||||
|
}))
|
||||||
|
}
|
||||||
129
backend/accounts-service/src/auth/reset/mod.rs
Normal file
129
backend/accounts-service/src/auth/reset/mod.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
pub mod handlers;
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::tokens::{hash_token, new_opaque_token};
|
||||||
|
|
||||||
|
/// One outgoing reset email. `token` is the plaintext token: the only place
|
||||||
|
/// it ever exists outside the response of `issue_reset_token`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Mail {
|
||||||
|
pub to: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delivery back-end for reset emails.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum MailBox {
|
||||||
|
/// Development delivery: a structured log line carrying the token, which
|
||||||
|
/// local/dev stacks pick up from the container logs. When a real provider
|
||||||
|
/// is wired in (lettre/SES/anything), this variant is the single switch
|
||||||
|
/// point - the endpoint contract does not change.
|
||||||
|
Log,
|
||||||
|
/// In-process queue: tests (and a future in-process mail worker) receive
|
||||||
|
/// every mail exactly as the handler produced it.
|
||||||
|
Queue(UnboundedSender<Mail>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MailBox {
|
||||||
|
pub fn send(&self, to: &str, token: &str) {
|
||||||
|
match self {
|
||||||
|
MailBox::Log => {
|
||||||
|
tracing::info!(
|
||||||
|
account = %to,
|
||||||
|
reset_token = %token,
|
||||||
|
"password reset requested; deliver the reset link to the account owner"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
MailBox::Queue(tx) => {
|
||||||
|
let _ = tx.send(Mail {
|
||||||
|
to: to.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset links must be used quickly: long windows turn a leaked email into
|
||||||
|
/// an account takeover. 30 minutes is the common industry compromise.
|
||||||
|
const TTL_MINUTES: i64 = 30;
|
||||||
|
|
||||||
|
pub const TOKEN_PREFIX: &str = "lvpr_";
|
||||||
|
|
||||||
|
/// A reset token is base64url like every other opaque token in this service
|
||||||
|
/// ("lvpr_" prefix + 43 chars), so the length check alone filters out most
|
||||||
|
/// junk before the database is ever touched.
|
||||||
|
pub fn is_well_formed_token(token: &str) -> bool {
|
||||||
|
token.len() == TOKEN_PREFIX.len() + 43 && token.starts_with(TOKEN_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidates any token still pending for the account, then stores the hash
|
||||||
|
/// of a fresh one. Returns the plaintext token for the mailer only — it is
|
||||||
|
/// never persisted in clear form.
|
||||||
|
pub async fn issue_reset_token(pool: &PgPool, account_id: Uuid) -> Result<String, sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE password_reset_tokens SET used_at = now()
|
||||||
|
WHERE account_id = $1 AND used_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
let token = new_opaque_token(TOKEN_PREFIX);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO password_reset_tokens (account_id, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, now() + make_interval(mins => $3::int))",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(hash_token(&token))
|
||||||
|
.bind(TTL_MINUTES)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically marks the token as used and returns its account. The single
|
||||||
|
/// conditional UPDATE makes double-spend impossible even for concurrent
|
||||||
|
/// callers: exactly one of them gets the row back.
|
||||||
|
pub async fn consume_reset_token(pool: &PgPool, token: &str) -> Result<Option<Uuid>, sqlx::Error> {
|
||||||
|
let row: Option<(Uuid,)> = sqlx::query_as(
|
||||||
|
"UPDATE password_reset_tokens SET used_at = now()
|
||||||
|
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
|
RETURNING account_id",
|
||||||
|
)
|
||||||
|
.bind(hash_token(token))
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(id,)| id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill every refresh session of the account: whoever holds a stolen session
|
||||||
|
/// cookie must not survive a password change.
|
||||||
|
pub async fn revoke_all_sessions(pool: &PgPool, account_id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE refresh_tokens SET revoked_at = now()
|
||||||
|
WHERE account_id = $1 AND revoked_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_formed_token_shape_matches_opaque_generator() {
|
||||||
|
let token = new_opaque_token(TOKEN_PREFIX);
|
||||||
|
assert!(is_well_formed_token(&token));
|
||||||
|
assert!(!is_well_formed_token(&format!("{token}x")));
|
||||||
|
assert!(!is_well_formed_token("lvpr_short"));
|
||||||
|
assert!(!is_well_formed_token(
|
||||||
|
"XWpr_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,9 +16,21 @@ use axum::{
|
||||||
};
|
};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use device::{handlers::DeviceState, store::DeviceStore};
|
use device::{handlers::DeviceState, store::DeviceStore};
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
||||||
let auth_state = AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure);
|
build_app_with_mail(pool, cfg, auth::reset::MailBox::Log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same router with a custom reset-email back-end: production uses the log
|
||||||
|
/// mailer, tests capture tokens through the queue variant.
|
||||||
|
pub fn build_app_with_mail(pool: sqlx::PgPool, cfg: &Config, mail: auth::reset::MailBox) -> Router {
|
||||||
|
let auth_state = AuthState::with_mail(
|
||||||
|
pool.clone(),
|
||||||
|
cfg.jwt_secret.clone(),
|
||||||
|
cfg.cookie_secure,
|
||||||
|
mail,
|
||||||
|
);
|
||||||
let device_state = DeviceState {
|
let device_state = DeviceState {
|
||||||
store: DeviceStore::default(),
|
store: DeviceStore::default(),
|
||||||
pool: pool.clone(),
|
pool: pool.clone(),
|
||||||
|
|
@ -43,6 +55,14 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
||||||
.route("/auth/login", post(auth::handlers::login))
|
.route("/auth/login", post(auth::handlers::login))
|
||||||
.route("/auth/refresh", post(auth::handlers::refresh))
|
.route("/auth/refresh", post(auth::handlers::refresh))
|
||||||
.route("/auth/logout", post(auth::handlers::logout))
|
.route("/auth/logout", post(auth::handlers::logout))
|
||||||
|
.route(
|
||||||
|
"/auth/forgot-password",
|
||||||
|
post(auth::reset::handlers::forgot_password),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/auth/reset-password",
|
||||||
|
post(auth::reset::handlers::reset_password),
|
||||||
|
)
|
||||||
.with_state(auth_state);
|
.with_state(auth_state);
|
||||||
|
|
||||||
let device_routes = Router::new()
|
let device_routes = Router::new()
|
||||||
|
|
@ -87,4 +107,5 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.merge(api)
|
.merge(api)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ use accounts_service::{build_app, config::Config};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let cfg = Config::from_env()?;
|
let cfg = Config::from_env()?;
|
||||||
cfg.validate()?;
|
cfg.validate()?;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ fn form(bytes: Vec<u8>) -> MultipartForm {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn valid_upload_stores_avatar_and_returns_url() {
|
async fn valid_upload_stores_avatar_and_returns_url() {
|
||||||
|
common::init_test_logging();
|
||||||
|
common::ensure_avatar_bucket().await;
|
||||||
let pool = common::test_pool().await;
|
let pool = common::test_pool().await;
|
||||||
let server = common::test_server(accounts_service::build_app(
|
let server = common::test_server(accounts_service::build_app(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ use uuid::Uuid;
|
||||||
// mounts as `mod common;`).
|
// mounts as `mod common;`).
|
||||||
pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
|
pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
|
||||||
|
|
||||||
|
pub fn init_test_logging() {
|
||||||
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn test_pool() -> sqlx::PgPool {
|
pub async fn test_pool() -> sqlx::PgPool {
|
||||||
let url = std::env::var("DATABASE_URL")
|
let url = std::env::var("DATABASE_URL")
|
||||||
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
|
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
|
||||||
|
|
@ -60,3 +64,65 @@ pub async fn register_account(server: &TestServer) -> (Uuid, String) {
|
||||||
email,
|
email,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates the test avatar bucket if it does not exist yet, so the avatar
|
||||||
|
/// tests are self-contained: any S3-compatible backend (MinIO, SeaweedFS)
|
||||||
|
/// works without external `mc mb` bootstrap. Mirrors `S3Storage::from_config`.
|
||||||
|
/// Retries briefly so a just-started container does not race the test.
|
||||||
|
pub async fn ensure_avatar_bucket() {
|
||||||
|
let creds =
|
||||||
|
aws_sdk_s3::config::Credentials::new("minioadmin", "minioadmin", None, None, "static");
|
||||||
|
let config = aws_sdk_s3::config::Builder::new()
|
||||||
|
.endpoint_url("http://localhost:9000")
|
||||||
|
.credentials_provider(creds)
|
||||||
|
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
||||||
|
.force_path_style(true)
|
||||||
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||||
|
.build();
|
||||||
|
let client = aws_sdk_s3::Client::from_conf(config);
|
||||||
|
let mut last_err = String::new();
|
||||||
|
for _ in 0..30 {
|
||||||
|
// Probe writability, not just bucket existence: a freshly started
|
||||||
|
// S3 backend may still be electing volumes ("Not enough data nodes"
|
||||||
|
// on SeaweedFS) right after create_bucket succeeds.
|
||||||
|
match async {
|
||||||
|
// Already-exists is success (MinIO: BucketAlreadyOwnedByYou,
|
||||||
|
// SeaweedFS: BucketAlreadyExists); anything else aborts.
|
||||||
|
if let Err(e) = client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket("lovisual-avatars-test")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let msg = format!("{e:?}");
|
||||||
|
if !msg.contains("BucketAlready") {
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket("lovisual-avatars-test")
|
||||||
|
.key(".probe")
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"probe"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let _ = client
|
||||||
|
.delete_object()
|
||||||
|
.bucket("lovisual-avatars-test")
|
||||||
|
.key(".probe")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
Ok::<(), String>(())
|
||||||
|
}
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => return,
|
||||||
|
Err(msg) => {
|
||||||
|
last_err = msg;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("S3 backend is not writable: {last_err}");
|
||||||
|
}
|
||||||
|
|
|
||||||
215
backend/accounts-service/tests/password_reset.rs
Normal file
215
backend/accounts-service/tests/password_reset.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use accounts_service::auth::reset::{Mail, MailBox};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum_test::TestServer;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
|
||||||
|
|
||||||
|
type App = (TestServer, PgPool, UnboundedReceiver<Mail>);
|
||||||
|
|
||||||
|
async fn app() -> App {
|
||||||
|
let (tx, rx) = unbounded_channel();
|
||||||
|
let pool = common::test_pool().await;
|
||||||
|
let server = common::test_server(accounts_service::build_app_with_mail(
|
||||||
|
pool.clone(),
|
||||||
|
&common::test_config(),
|
||||||
|
MailBox::Queue(tx),
|
||||||
|
));
|
||||||
|
(server, pool, rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_reset(server: &TestServer, email: &str) {
|
||||||
|
server
|
||||||
|
.post("/auth/forgot-password")
|
||||||
|
.json(&serde_json::json!({ "email": email }))
|
||||||
|
.await
|
||||||
|
.assert_status(StatusCode::ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_with(server: &TestServer, token: &str, password: &str) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.post("/auth/reset-password")
|
||||||
|
.json(&serde_json::json!({ "token": token, "new_password": password }))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn forgot_password_never_reveals_account_existence() {
|
||||||
|
let (server, _pool, _mail) = app().await;
|
||||||
|
|
||||||
|
// Unknown email and known email must be indistinguishable: same status,
|
||||||
|
// same body. Enumeration is the first step of account takeover.
|
||||||
|
let unknown = server
|
||||||
|
.post("/auth/forgot-password")
|
||||||
|
.json(&serde_json::json!({ "email": "nobody-here@example.com" }))
|
||||||
|
.await;
|
||||||
|
unknown.assert_status(StatusCode::ACCEPTED);
|
||||||
|
let unknown_body: serde_json::Value = unknown.json();
|
||||||
|
assert_eq!(
|
||||||
|
unknown_body["status"],
|
||||||
|
"reset email sent if the account exists"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, email) = common::register_account(&server).await;
|
||||||
|
let known = server
|
||||||
|
.post("/auth/forgot-password")
|
||||||
|
.json(&serde_json::json!({ "email": email }))
|
||||||
|
.await;
|
||||||
|
known.assert_status(StatusCode::ACCEPTED);
|
||||||
|
let known_body: serde_json::Value = known.json();
|
||||||
|
assert_eq!(unknown_body, known_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_reset_flow_changes_password_and_kills_sessions() {
|
||||||
|
let (server, _pool, mut mail) = app().await;
|
||||||
|
let (_, email) = common::register_account(&server).await;
|
||||||
|
let old_password = "correct-horse-battery-staple";
|
||||||
|
|
||||||
|
// Log in to create a live refresh session the reset must kill.
|
||||||
|
let login = server
|
||||||
|
.post("/auth/login")
|
||||||
|
.json(&serde_json::json!({ "email": email, "password": old_password }))
|
||||||
|
.await;
|
||||||
|
login.assert_status_ok();
|
||||||
|
let old_refresh = refresh_cookie(&login).expect("login must set the refresh cookie");
|
||||||
|
|
||||||
|
request_reset(&server, &email).await;
|
||||||
|
let token = mail.recv().await.expect("queue mailer must deliver").token;
|
||||||
|
|
||||||
|
reset_with(&server, &token, "brand-new-password-1")
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Old password is dead, new password works.
|
||||||
|
server
|
||||||
|
.post("/auth/login")
|
||||||
|
.json(&serde_json::json!({ "email": email, "password": old_password }))
|
||||||
|
.await
|
||||||
|
.assert_status(StatusCode::UNAUTHORIZED);
|
||||||
|
server
|
||||||
|
.post("/auth/login")
|
||||||
|
.json(&serde_json::json!({ "email": email, "password": "brand-new-password-1" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Every refresh session issued before the reset is revoked: replaying
|
||||||
|
// the pre-reset cookie must not survive (a stolen session cannot
|
||||||
|
// outlive a password change).
|
||||||
|
let replay = server
|
||||||
|
.post("/auth/refresh")
|
||||||
|
.add_cookie(old_refresh.as_str().into())
|
||||||
|
.await;
|
||||||
|
replay.assert_status(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reset_token_is_single_use() {
|
||||||
|
let (server, _pool, mut mail) = app().await;
|
||||||
|
let (_, email) = common::register_account(&server).await;
|
||||||
|
|
||||||
|
request_reset(&server, &email).await;
|
||||||
|
let token = mail.recv().await.expect("mail").token;
|
||||||
|
|
||||||
|
reset_with(&server, &token, "brand-new-password-1")
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
reset_with(&server, &token, "another-password-2")
|
||||||
|
.await
|
||||||
|
.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn new_request_supersedes_pending_token() {
|
||||||
|
let (server, _pool, mut mail) = app().await;
|
||||||
|
let (_, email) = common::register_account(&server).await;
|
||||||
|
|
||||||
|
request_reset(&server, &email).await;
|
||||||
|
let first = mail.recv().await.expect("mail").token;
|
||||||
|
request_reset(&server, &email).await;
|
||||||
|
let second = mail.recv().await.expect("mail").token;
|
||||||
|
assert_ne!(first, second);
|
||||||
|
|
||||||
|
// The superseded token no longer works, the fresh one does.
|
||||||
|
reset_with(&server, &first, "brand-new-password-1")
|
||||||
|
.await
|
||||||
|
.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
reset_with(&server, &second, "brand-new-password-1")
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bad_tokens_are_rejected_without_oracle() {
|
||||||
|
let (server, _pool, _mail) = app().await;
|
||||||
|
|
||||||
|
server
|
||||||
|
.post("/auth/reset-password")
|
||||||
|
.json(&serde_json::json!({ "token": "short", "new_password": "brand-new-password-1" }))
|
||||||
|
.await
|
||||||
|
.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Unknown but well-formed token: same 400 family, and unlike the
|
||||||
|
// shape-rejection above it must not leak which of unknown/expired/used
|
||||||
|
// it is.
|
||||||
|
let unknown = server
|
||||||
|
.post("/auth/reset-password")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"token": "lvpr_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
"new_password": "brand-new-password-1"
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
unknown.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
let body: serde_json::Value = unknown.json();
|
||||||
|
assert_eq!(body["error"], "reset token is invalid or expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn weak_password_is_rejected_before_token_consumption() {
|
||||||
|
let (server, _pool, mut mail) = app().await;
|
||||||
|
let (_, email) = common::register_account(&server).await;
|
||||||
|
|
||||||
|
request_reset(&server, &email).await;
|
||||||
|
let token = mail.recv().await.expect("mail").token;
|
||||||
|
|
||||||
|
reset_with(&server, &token, "short12")
|
||||||
|
.await
|
||||||
|
.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// The token must still be usable afterwards: rejecting a weak password
|
||||||
|
// must not burn the user's one link.
|
||||||
|
reset_with(&server, &token, "brand-new-password-1")
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reset_mail_only_goes_to_known_accounts() {
|
||||||
|
let (server, _pool, mut mail) = app().await;
|
||||||
|
|
||||||
|
request_reset(&server, "ghost@example.com").await;
|
||||||
|
assert!(
|
||||||
|
mail.try_recv().is_err(),
|
||||||
|
"unknown account must not enqueue a reset email"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, email) = common::register_account(&server).await;
|
||||||
|
request_reset(&server, &email).await;
|
||||||
|
let sent = mail.recv().await.expect("mail");
|
||||||
|
assert_eq!(sent.to, email);
|
||||||
|
assert!(sent.token.starts_with("lvpr_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refresh cookie is httpOnly and scoped to /auth; axum-test exposes
|
||||||
|
/// response headers, so parse Set-Cookie directly.
|
||||||
|
fn refresh_cookie(res: &axum_test::TestResponse) -> Option<String> {
|
||||||
|
res.headers()
|
||||||
|
.get_all(axum::http::header::SET_COOKIE)
|
||||||
|
.iter()
|
||||||
|
.find_map(|v| {
|
||||||
|
let s = v.to_str().ok()?;
|
||||||
|
s.strip_prefix("lv_refresh=")
|
||||||
|
.map(|rest| format!("lv_refresh={}", rest.split(';').next().unwrap_or("")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -10,9 +10,10 @@ path = "src/lib.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
axum = { version = "0.8", features = ["macros"] }
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
tower-http = { version = "0.7", features = ["trace"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
|
|
||||||
use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
use common::internal::{InternalKey, require_internal_key};
|
use common::internal::{InternalKey, require_internal_key};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use presence::store::Store;
|
use presence::store::Store;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
/// Presence requests are tiny JSON (a ≤64-char payload and ≤40 uuids).
|
/// Presence requests are tiny JSON (a ≤64-char payload and ≤40 uuids).
|
||||||
const MAX_BODY_BYTES: usize = 8 * 1024;
|
const MAX_BODY_BYTES: usize = 8 * 1024;
|
||||||
|
|
@ -22,6 +27,7 @@ pub fn build_app(cfg: &Config) -> (Router, Arc<Store>) {
|
||||||
));
|
));
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.merge(api);
|
.merge(api)
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
(app, store)
|
(app, store)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
let cfg = chat_service::config::Config::from_env()?;
|
let cfg = chat_service::config::Config::from_env()?;
|
||||||
cfg.validate()?;
|
cfg.validate()?;
|
||||||
let (app, state) = chat_service::build_app(&cfg);
|
let (app, state) = chat_service::build_app(&cfg);
|
||||||
|
|
|
||||||
|
|
@ -63,15 +63,23 @@ pub async fn sync(
|
||||||
}
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if req.publish
|
if req.publish
|
||||||
&& let Err(PublishError::Claimed) = store.publish(id.account_id, &server, req.mc, req.gui, now)
|
&& let Err(PublishError::Claimed) =
|
||||||
|
store.publish(id.account_id, &server, req.mc, req.gui, now)
|
||||||
{
|
{
|
||||||
return error(StatusCode::CONFLICT, "player uuid is bound to another account");
|
return error(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"player uuid is bound to another account",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let players: Vec<PlayerState> = store
|
let players: Vec<PlayerState> = store
|
||||||
.lookup(&server, &req.want, now)
|
.lookup(&server, &req.want, now)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.mc != req.mc)
|
.filter(|s| s.mc != req.mc)
|
||||||
.map(|s| PlayerState { mc: s.mc, gui: s.gui, age: s.age_ms })
|
.map(|s| PlayerState {
|
||||||
|
mc: s.mc,
|
||||||
|
gui: s.gui,
|
||||||
|
age: s.age_ms,
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Json(json!({ "players": players })).into_response()
|
Json(json!({ "players": players })).into_response()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ pub mod handlers;
|
||||||
pub mod payload;
|
pub mod payload;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
use std::{sync::Arc, time::{Duration, Instant}};
|
use std::{
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
use store::Store;
|
use store::Store;
|
||||||
|
|
||||||
pub fn spawn_purger(store: Arc<Store>) {
|
pub fn spawn_purger(store: Arc<Store>) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ pub const MAX_WANT: usize = 40;
|
||||||
pub fn valid_payload(s: &str) -> bool {
|
pub fn valid_payload(s: &str) -> bool {
|
||||||
!s.is_empty()
|
!s.is_empty()
|
||||||
&& s.len() <= MAX_PAYLOAD_CHARS
|
&& s.len() <= MAX_PAYLOAD_CHARS
|
||||||
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
|
&& s.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalised server key: trimmed, lower-case, no control characters, bounded.
|
/// Normalised server key: trimmed, lower-case, no control characters, bounded.
|
||||||
|
|
@ -38,7 +39,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_is_normalised() {
|
fn server_is_normalised() {
|
||||||
assert_eq!(normalize_server(" Play.Example.COM ").as_deref(), Some("play.example.com"));
|
assert_eq!(
|
||||||
|
normalize_server(" Play.Example.COM ").as_deref(),
|
||||||
|
Some("play.example.com")
|
||||||
|
);
|
||||||
assert_eq!(normalize_server(""), None);
|
assert_eq!(normalize_server(""), None);
|
||||||
assert_eq!(normalize_server("a\nb"), None);
|
assert_eq!(normalize_server("a\nb"), None);
|
||||||
assert_eq!(normalize_server(&"x".repeat(65)), None);
|
assert_eq!(normalize_server(&"x".repeat(65)), None);
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ impl Store {
|
||||||
|
|
||||||
pub fn purge(&self, now: Instant) {
|
pub fn purge(&self, now: Instant) {
|
||||||
let mut inner = self.inner.lock().expect("presence lock");
|
let mut inner = self.inner.lock().expect("presence lock");
|
||||||
inner.entries.retain(|_, e| now.duration_since(e.updated) < ENTRY_TTL);
|
inner
|
||||||
|
.entries
|
||||||
|
.retain(|_, e| now.duration_since(e.updated) < ENTRY_TTL);
|
||||||
let expired: Vec<Uuid> = inner
|
let expired: Vec<Uuid> = inner
|
||||||
.claims
|
.claims
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -117,7 +119,12 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn ids() -> (Uuid, Uuid, Uuid, Uuid) {
|
fn ids() -> (Uuid, Uuid, Uuid, Uuid) {
|
||||||
(Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(10), Uuid::from_u128(20))
|
(
|
||||||
|
Uuid::from_u128(1),
|
||||||
|
Uuid::from_u128(2),
|
||||||
|
Uuid::from_u128(10),
|
||||||
|
Uuid::from_u128(20),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -125,7 +132,9 @@ mod tests {
|
||||||
let (acc, _, mc, _) = ids();
|
let (acc, _, mc, _) = ids();
|
||||||
let store = Store::default();
|
let store = Store::default();
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
store.publish(acc, "srv", mc, Some("AAAA".into()), t0).unwrap();
|
store
|
||||||
|
.publish(acc, "srv", mc, Some("AAAA".into()), t0)
|
||||||
|
.unwrap();
|
||||||
let seen = store.lookup("srv", &[mc], t0 + Duration::from_millis(500));
|
let seen = store.lookup("srv", &[mc], t0 + Duration::from_millis(500));
|
||||||
assert_eq!(seen.len(), 1);
|
assert_eq!(seen.len(), 1);
|
||||||
assert_eq!(seen[0].gui.as_deref(), Some("AAAA"));
|
assert_eq!(seen[0].gui.as_deref(), Some("AAAA"));
|
||||||
|
|
@ -149,7 +158,10 @@ mod tests {
|
||||||
let store = Store::default();
|
let store = Store::default();
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
store.publish(acc, "s", mc, None, t0).unwrap();
|
store.publish(acc, "s", mc, None, t0).unwrap();
|
||||||
assert_eq!(store.publish(other, "s", mc, None, t0 + Duration::from_secs(1)), Err(PublishError::Claimed));
|
assert_eq!(
|
||||||
|
store.publish(other, "s", mc, None, t0 + Duration::from_secs(1)),
|
||||||
|
Err(PublishError::Claimed)
|
||||||
|
);
|
||||||
// after the claim goes idle it can be taken over
|
// after the claim goes idle it can be taken over
|
||||||
assert!(store.publish(other, "s", mc, None, t0 + CLAIM_TTL).is_ok());
|
assert!(store.publish(other, "s", mc, None, t0 + CLAIM_TTL).is_ok());
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +195,10 @@ mod tests {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
store.publish(acc, "s", mc, None, t0).unwrap();
|
store.publish(acc, "s", mc, None, t0).unwrap();
|
||||||
store.purge(t0 + CLAIM_TTL);
|
store.purge(t0 + CLAIM_TTL);
|
||||||
assert!(store.publish(Uuid::from_u128(2), "s", mc, None, t0 + CLAIM_TTL).is_ok());
|
assert!(
|
||||||
|
store
|
||||||
|
.publish(Uuid::from_u128(2), "s", mc, None, t0 + CLAIM_TTL)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ use uuid::Uuid;
|
||||||
const KEY: &str = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk";
|
const KEY: &str = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk";
|
||||||
|
|
||||||
fn server() -> TestServer {
|
fn server() -> TestServer {
|
||||||
let cfg = Config { port: 0, internal_key: KEY.into() };
|
let cfg = Config {
|
||||||
|
port: 0,
|
||||||
|
internal_key: KEY.into(),
|
||||||
|
};
|
||||||
let (app, _) = chat_service::build_app(&cfg);
|
let (app, _) = chat_service::build_app(&cfg);
|
||||||
TestServer::new(app)
|
TestServer::new(app)
|
||||||
}
|
}
|
||||||
|
|
@ -37,10 +40,20 @@ async fn requires_internal_key_and_identity() {
|
||||||
async fn two_players_see_each_other() {
|
async fn two_players_see_each_other() {
|
||||||
let s = server();
|
let s = server();
|
||||||
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
|
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
|
||||||
let r = sync(&s, 1, json!({"server": "Play.X", "mc": a, "gui": "AQEAAAA=", "want": [b]})).await;
|
let r = sync(
|
||||||
|
&s,
|
||||||
|
1,
|
||||||
|
json!({"server": "Play.X", "mc": a, "gui": "AQEAAAA=", "want": [b]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(r.status_code(), 200);
|
assert_eq!(r.status_code(), 200);
|
||||||
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
|
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
|
||||||
let r = sync(&s, 2, json!({"server": "play.x", "mc": b, "gui": null, "want": [a]})).await;
|
let r = sync(
|
||||||
|
&s,
|
||||||
|
2,
|
||||||
|
json!({"server": "play.x", "mc": b, "gui": null, "want": [a]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let body = r.json::<Value>();
|
let body = r.json::<Value>();
|
||||||
let players = body["players"].as_array().unwrap();
|
let players = body["players"].as_array().unwrap();
|
||||||
assert_eq!(players.len(), 1);
|
assert_eq!(players.len(), 1);
|
||||||
|
|
@ -56,7 +69,12 @@ async fn rejects_bad_input_and_uuid_theft() {
|
||||||
assert_eq!(r.status_code(), 400);
|
assert_eq!(r.status_code(), 400);
|
||||||
let r = sync(&s, 1, json!({"server": "", "mc": mc})).await;
|
let r = sync(&s, 1, json!({"server": "", "mc": mc})).await;
|
||||||
assert_eq!(r.status_code(), 400);
|
assert_eq!(r.status_code(), 400);
|
||||||
assert_eq!(sync(&s, 1, json!({"server": "x", "mc": mc})).await.status_code(), 200);
|
assert_eq!(
|
||||||
|
sync(&s, 1, json!({"server": "x", "mc": mc}))
|
||||||
|
.await
|
||||||
|
.status_code(),
|
||||||
|
200
|
||||||
|
);
|
||||||
let r = sync(&s, 2, json!({"server": "x", "mc": mc})).await;
|
let r = sync(&s, 2, json!({"server": "x", "mc": mc})).await;
|
||||||
assert_eq!(r.status_code(), 409);
|
assert_eq!(r.status_code(), 409);
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +83,12 @@ async fn rejects_bad_input_and_uuid_theft() {
|
||||||
async fn read_only_poll_does_not_publish() {
|
async fn read_only_poll_does_not_publish() {
|
||||||
let s = server();
|
let s = server();
|
||||||
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
|
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
|
||||||
sync(&s, 1, json!({"server": "x", "mc": a, "publish": false, "want": [b]})).await;
|
sync(
|
||||||
|
&s,
|
||||||
|
1,
|
||||||
|
json!({"server": "x", "mc": a, "publish": false, "want": [b]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let r = sync(&s, 2, json!({"server": "x", "mc": b, "want": [a]})).await;
|
let r = sync(&s, 2, json!({"server": "x", "mc": b, "want": [a]})).await;
|
||||||
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
|
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ path = "src/lib.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
axum = { version = "0.8", features = ["macros"] }
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
tower-http = { version = "0.7", features = ["trace"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] }
|
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] }
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use common::internal::{InternalKey, require_internal_key};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use showcase::profiles::ProfileSource;
|
use showcase::profiles::ProfileSource;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
pub fn build_app(pool: sqlx::PgPool, cfg: &Config, profiles: Arc<dyn ProfileSource>) -> Router {
|
pub fn build_app(pool: sqlx::PgPool, cfg: &Config, profiles: Arc<dyn ProfileSource>) -> Router {
|
||||||
let slots_state = slots::handlers::SlotsState { pool: pool.clone() };
|
let slots_state = slots::handlers::SlotsState { pool: pool.clone() };
|
||||||
|
|
@ -52,4 +53,5 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config, profiles: Arc<dyn ProfileSour
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.merge(api)
|
.merge(api)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
let cfg = configs_service::config::Config::from_env()?;
|
let cfg = configs_service::config::Config::from_env()?;
|
||||||
cfg.validate()?;
|
cfg.validate()?;
|
||||||
let pool = sqlx::PgPool::connect(&cfg.database_url).await?;
|
let pool = sqlx::PgPool::connect(&cfg.database_url).await?;
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,12 @@ services:
|
||||||
ACCOUNTS_GRPC_URL: http://accounts-service:50051
|
ACCOUNTS_GRPC_URL: http://accounts-service:50051
|
||||||
CONFIGS_HTTP_URL: http://configs-service:8082
|
CONFIGS_HTTP_URL: http://configs-service:8082
|
||||||
CHAT_HTTP_URL: http://chat-service:8083
|
CHAT_HTTP_URL: http://chat-service:8083
|
||||||
|
# This stack is only ever exposed through nginx (see deploy/), so the
|
||||||
|
# rate limiter must read the client IP from X-Forwarded-For. Without
|
||||||
|
# this every client shares the proxy's socket address and one bucket:
|
||||||
|
# the global 300/min and login 5/min would be site-wide self-DoS.
|
||||||
|
# Keep TRUST_PROXY=false in .env for direct-exposure dev runs.
|
||||||
|
TRUST_PROXY: "true"
|
||||||
# Read-only static files served under GET /downloads/* (lovisual.jar).
|
# Read-only static files served under GET /downloads/* (lovisual.jar).
|
||||||
DOWNLOADS_DIR: /srv/downloads
|
DOWNLOADS_DIR: /srv/downloads
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ http-body-util = "0.1"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
||||||
tower-http = { version = "0.7", features = ["cors", "trace", "fs"] }
|
tower-http = { version = "0.7", features = ["cors", "trace", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
|
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use axum::{
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use identity::device::DeviceAuthenticator;
|
use identity::device::DeviceAuthenticator;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::{cors::CorsLayer, services::ServeDir};
|
use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer};
|
||||||
|
|
||||||
pub fn build_app(cfg: &Config, devices: Arc<dyn DeviceAuthenticator>) -> Router {
|
pub fn build_app(cfg: &Config, devices: Arc<dyn DeviceAuthenticator>) -> Router {
|
||||||
let upstreams = Arc::new(proxy::forward::Upstreams::new(cfg).expect("valid upstream config"));
|
let upstreams = Arc::new(proxy::forward::Upstreams::new(cfg).expect("valid upstream config"));
|
||||||
|
|
@ -69,6 +69,9 @@ pub fn build_app(cfg: &Config, devices: Arc<dyn DeviceAuthenticator>) -> Router
|
||||||
guard::reject_ambiguous_paths,
|
guard::reject_ambiguous_paths,
|
||||||
))
|
))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
|
// Outermost so every route (health, downloads, proxied API) is
|
||||||
|
// traced; spans carry method/path/status for the fmt subscriber.
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_purger(limits: Arc<rate_limit::RateLimits>) {
|
fn spawn_purger(limits: Arc<rate_limit::RateLimits>) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@ use std::sync::Arc;
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
let cfg = gateway::config::Config::from_env()?;
|
let cfg = gateway::config::Config::from_env()?;
|
||||||
cfg.validate()?;
|
cfg.validate()?;
|
||||||
let devices = Arc::new(GrpcDevices::connect_lazy(
|
let devices = Arc::new(GrpcDevices::connect_lazy(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,20 @@ pub fn rules() -> Vec<Rule> {
|
||||||
vec![
|
vec![
|
||||||
rule(Method::POST, "/auth/login", Quota::per_minute(n(5)), Ip),
|
rule(Method::POST, "/auth/login", Quota::per_minute(n(5)), Ip),
|
||||||
rule(Method::POST, "/auth/register", Quota::per_hour(n(3)), Ip),
|
rule(Method::POST, "/auth/register", Quota::per_hour(n(3)), Ip),
|
||||||
|
// Same threat as register: each accepted request sends an email, so
|
||||||
|
// the mailer must not be usable as a spam cannon.
|
||||||
|
rule(
|
||||||
|
Method::POST,
|
||||||
|
"/auth/forgot-password",
|
||||||
|
Quota::per_hour(n(3)),
|
||||||
|
Ip,
|
||||||
|
),
|
||||||
|
rule(
|
||||||
|
Method::POST,
|
||||||
|
"/auth/reset-password",
|
||||||
|
Quota::per_minute(n(10)),
|
||||||
|
Ip,
|
||||||
|
),
|
||||||
rule(Method::POST, "/auth/refresh", Quota::per_minute(n(30)), Ip),
|
rule(Method::POST, "/auth/refresh", Quota::per_minute(n(30)), Ip),
|
||||||
rule(Method::POST, "/device/code", Quota::per_minute(n(10)), Ip),
|
rule(Method::POST, "/device/code", Quota::per_minute(n(10)), Ip),
|
||||||
// The mod polls every 2–3 s for up to 10 min: 10/min would break linking.
|
// The mod polls every 2–3 s for up to 10 min: 10/min would break linking.
|
||||||
|
|
@ -61,7 +75,12 @@ pub fn rules() -> Vec<Rule> {
|
||||||
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
|
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
|
||||||
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
|
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
|
||||||
// The mod syncs menu presence about 3 times a second while a menu is open.
|
// The mod syncs menu presence about 3 times a second while a menu is open.
|
||||||
rule(Method::POST, "/presence/*", Quota::per_second(n(6)), Account),
|
rule(
|
||||||
|
Method::POST,
|
||||||
|
"/presence/*",
|
||||||
|
Quota::per_second(n(6)),
|
||||||
|
Account,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||||
import { SessionProvider } from '@features/auth/session'
|
import { SessionProvider } from '@features/auth/session/session'
|
||||||
import { ActiveThemeProvider } from '@features/themes/useActiveTheme'
|
import { ActiveThemeProvider } from '@features/themes/useActiveTheme'
|
||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useCallback, useRef, useState, type DragEvent } from 'react'
|
||||||
import { useTranslation } from '@shared/i18n/react'
|
import { useTranslation } from '@shared/i18n/react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { Button } from '@shared/ui/Button'
|
import { Button } from '@shared/ui/Button'
|
||||||
import { useSession } from '@features/auth/session'
|
import { useSession } from '@features/auth/session/useSession'
|
||||||
import { AvatarUpload } from './avatar/AvatarUpload'
|
import { AvatarUpload } from './avatar/AvatarUpload'
|
||||||
|
|
||||||
/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. The avatar circle doubles as a file drop target. */
|
/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. The avatar circle doubles as a file drop target. */
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useCallback, useEffect, useId, useState, type ChangeEvent } from 'react'
|
import { useCallback, useEffect, useId, useState, type ChangeEvent } from 'react'
|
||||||
import { useTranslation } from '@shared/i18n/react'
|
import { useTranslation } from '@shared/i18n/react'
|
||||||
import { Notice } from '@shared/ui/Notice'
|
import { Notice } from '@shared/ui/Notice'
|
||||||
import { useSession } from '@features/auth/session'
|
import { useSession } from '@features/auth/session/useSession'
|
||||||
import {
|
import {
|
||||||
addAvatarHistory,
|
addAvatarHistory,
|
||||||
checkAvatarFile,
|
checkAvatarFile,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useTranslation } from '@shared/i18n/react'
|
import { useTranslation } from '@shared/i18n/react'
|
||||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||||
import { useSession } from './session'
|
import { useSession } from './session/useSession'
|
||||||
|
|
||||||
export function RequireAuth() {
|
export function RequireAuth() {
|
||||||
const { t } = useTranslation('auth')
|
const { t } = useTranslation('auth')
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { describeError } from '@shared/api/errors'
|
||||||
import { Button } from '@shared/ui/Button'
|
import { Button } from '@shared/ui/Button'
|
||||||
import { Notice } from '@shared/ui/Notice'
|
import { Notice } from '@shared/ui/Notice'
|
||||||
import { TextField } from '@shared/ui/text-field/TextField'
|
import { TextField } from '@shared/ui/text-field/TextField'
|
||||||
import { useSession } from '../session'
|
import { useSession } from '../session/useSession'
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { t } = useTranslation('auth')
|
const { t } = useTranslation('auth')
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { describeError } from '@shared/api/errors'
|
||||||
import { Button } from '@shared/ui/Button'
|
import { Button } from '@shared/ui/Button'
|
||||||
import { Notice } from '@shared/ui/Notice'
|
import { Notice } from '@shared/ui/Notice'
|
||||||
import { TextField } from '@shared/ui/text-field/TextField'
|
import { TextField } from '@shared/ui/text-field/TextField'
|
||||||
import { useSession } from '../session'
|
import { useSession } from '../session/useSession'
|
||||||
import { validateEmail, validateNick, validatePassword } from '../validation'
|
import { validateEmail, validateNick, validatePassword } from '../validation'
|
||||||
|
|
||||||
export function RegisterForm() {
|
export function RegisterForm() {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,10 @@
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
import { useCallback, useMemo, type ReactNode } from 'react'
|
||||||
import { api } from '@shared/api/client'
|
import { api } from '@shared/api/client'
|
||||||
import type { Me } from '@shared/types'
|
import { fetchMe, login, logout, register } from '../api'
|
||||||
import { fetchMe, login, logout, register } from './api'
|
import { SessionContext, type Session } from './sessionContext'
|
||||||
import { hasSessionHint, setSessionHint } from './sessionHint'
|
import { hasSessionHint, setSessionHint } from './sessionHint'
|
||||||
|
|
||||||
type Session = {
|
|
||||||
status: 'loading' | 'anonymous' | 'authenticated'
|
|
||||||
me: Me | null
|
|
||||||
signIn(email: string, password: string): Promise<void>
|
|
||||||
signUp(email: string, password: string, nick: string): Promise<void>
|
|
||||||
signOut(): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const SessionContext = createContext<Session | null>(null)
|
|
||||||
|
|
||||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
const client = useQueryClient()
|
const client = useQueryClient()
|
||||||
// Restores the session after a reload: the refresh cookie survives, the
|
// Restores the session after a reload: the refresh cookie survives, the
|
||||||
|
|
@ -64,9 +54,3 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
return <SessionContext value={value}>{children}</SessionContext>
|
return <SessionContext value={value}>{children}</SessionContext>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSession(): Session {
|
|
||||||
const session = useContext(SessionContext)
|
|
||||||
if (!session) throw new Error('useSession outside SessionProvider')
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
12
frontend/src/features/auth/session/sessionContext.ts
Normal file
12
frontend/src/features/auth/session/sessionContext.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { createContext } from 'react'
|
||||||
|
import type { Me } from '@shared/types'
|
||||||
|
|
||||||
|
export type Session = {
|
||||||
|
status: 'loading' | 'anonymous' | 'authenticated'
|
||||||
|
me: Me | null
|
||||||
|
signIn(email: string, password: string): Promise<void>
|
||||||
|
signUp(email: string, password: string, nick: string): Promise<void>
|
||||||
|
signOut(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionContext = createContext<Session | null>(null)
|
||||||
8
frontend/src/features/auth/session/useSession.ts
Normal file
8
frontend/src/features/auth/session/useSession.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { useContext } from 'react'
|
||||||
|
import { SessionContext, type Session } from './sessionContext'
|
||||||
|
|
||||||
|
export function useSession(): Session {
|
||||||
|
const session = useContext(SessionContext)
|
||||||
|
if (!session) throw new Error('useSession outside SessionProvider')
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { screen } from '@testing-library/react'
|
import { screen } from '@testing-library/react'
|
||||||
import { expect, test } from 'vitest'
|
import { expect, test } from 'vitest'
|
||||||
import { RequireAuth } from '../RequireAuth'
|
import { RequireAuth } from '../RequireAuth'
|
||||||
import { useSession } from '../session'
|
import { useSession } from '../session/useSession'
|
||||||
import { renderApp } from '@test/render'
|
import { renderApp } from '@test/render'
|
||||||
import { json, mockFetch } from '@test/fetch'
|
import { json, mockFetch } from '@test/fetch'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from '@shared/i18n/react'
|
import { useTranslation } from '@shared/i18n/react'
|
||||||
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
|
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
|
||||||
import { ButtonLink } from '@shared/ui/Button'
|
import { ButtonLink } from '@shared/ui/Button'
|
||||||
import { useSession } from '../auth/session'
|
import { useSession } from '../auth/session/useSession'
|
||||||
import { useInView } from './effects/useInView'
|
import { useInView } from './effects/useInView'
|
||||||
import '@features/landing/styles/sections.css'
|
import '@features/landing/styles/sections.css'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Hero } from '../sections/hero/Hero'
|
||||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { initI18n } from '@app/i18n'
|
import { initI18n } from '@app/i18n'
|
||||||
import { SessionProvider } from '@features/auth/session'
|
import { SessionProvider } from '@features/auth/session/session'
|
||||||
import { json, mockFetch } from '@test/fetch'
|
import { json, mockFetch } from '@test/fetch'
|
||||||
|
|
||||||
test('reduced motion shows the full command at once', async () => {
|
test('reduced motion shows the full command at once', async () => {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useTranslation } from '@shared/i18n/react'
|
||||||
import { Link, useLocation } from 'react-router'
|
import { Link, useLocation } from 'react-router'
|
||||||
import { Notice } from '@shared/ui/Notice'
|
import { Notice } from '@shared/ui/Notice'
|
||||||
import { ApiError } from '@shared/api/client'
|
import { ApiError } from '@shared/api/client'
|
||||||
import { useSession } from '@features/auth/session'
|
import { useSession } from '@features/auth/session/useSession'
|
||||||
import { listSlots } from '@features/configs/api'
|
import { listSlots } from '@features/configs/api'
|
||||||
import { copyToSlot } from '../api'
|
import { copyToSlot } from '../api'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useTranslation } from '@shared/i18n/react'
|
import { useTranslation } from '@shared/i18n/react'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { useSession } from '@features/auth/session'
|
import { useSession } from '@features/auth/session/useSession'
|
||||||
import type { Me } from '../../types'
|
import type { Me } from '../../types'
|
||||||
|
|
||||||
/** The pixel-heart mark, standalone (no badge background) — same shape as public/favicon.svg. */
|
/** The pixel-heart mark, standalone (no badge background) — same shape as public/favicon.svg. */
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render } from '@testing-library/react'
|
import { render } from '@testing-library/react'
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router'
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router'
|
||||||
import { SessionProvider } from '@features/auth/session'
|
import { SessionProvider } from '@features/auth/session/session'
|
||||||
|
|
||||||
export function renderApp(routes: RouteObject[], initialPath = '/') {
|
export function renderApp(routes: RouteObject[], initialPath = '/') {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import dev.loki.lovisual.Lifecycle;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@HudElementInfo(
|
@HudElementInfo(
|
||||||
id = "better_chat",
|
id = "better_chat",
|
||||||
displayName = "Better Chat",
|
displayName = "Better Chat",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget;
|
||||||
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
||||||
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@HudElementInfo(
|
@HudElementInfo(
|
||||||
id = "xyz",
|
id = "xyz",
|
||||||
displayName = "XYZ",
|
displayName = "XYZ",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget;
|
||||||
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
||||||
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@HudElementInfo(
|
@HudElementInfo(
|
||||||
id = "speed_bps",
|
id = "speed_bps",
|
||||||
displayName = "Speed BPS",
|
displayName = "Speed BPS",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ final class KeybindsData {
|
||||||
if (category == null) return "ellipsis";
|
if (category == null) return "ellipsis";
|
||||||
return switch (category) {
|
return switch (category) {
|
||||||
case COMBAT -> "swords";
|
case COMBAT -> "swords";
|
||||||
case MOVEMENT -> "accessibility";
|
|
||||||
case PLAYER -> "user";
|
case PLAYER -> "user";
|
||||||
case VISUALS -> "tree-pine";
|
case VISUALS -> "tree-pine";
|
||||||
case ACCESSORIES -> "shirt";
|
case ACCESSORIES -> "shirt";
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget;
|
||||||
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
||||||
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@HudElementInfo(
|
@HudElementInfo(
|
||||||
id = "ping",
|
id = "ping",
|
||||||
displayName = "Ping",
|
displayName = "Ping",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import dev.loki.lovisual.render.engine.math.HudScale;
|
||||||
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
||||||
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@HudElementInfo(
|
@HudElementInfo(
|
||||||
id = "armor",
|
id = "armor",
|
||||||
displayName = "Armor",
|
displayName = "Armor",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package dev.loki.lovisual.features.module.core;
|
||||||
|
|
||||||
public enum ModuleCategory {
|
public enum ModuleCategory {
|
||||||
COMBAT,
|
COMBAT,
|
||||||
MOVEMENT,
|
|
||||||
PLAYER,
|
PLAYER,
|
||||||
VISUALS,
|
VISUALS,
|
||||||
ACCESSORIES,
|
ACCESSORIES,
|
||||||
|
|
|
||||||
|
|
@ -190,5 +190,7 @@ public enum ModuleManager {
|
||||||
catch (Throwable ignored) { DebugLog.error("Failed to load config for module %s", ignored, m.name()); }
|
catch (Throwable ignored) { DebugLog.error("Failed to load config for module %s", ignored, m.name()); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void saveAllModuleConfigs() { for (Module m : modulesSnapshot) { try { m.saveConfig(); } catch (Throwable ignored) { } } }
|
/** Save config for every registered module; logs per-module failures without aborting. */
|
||||||
|
public static void saveAllModuleConfigs() { for (Module m : modulesSnapshot) try { m.saveConfig(); }
|
||||||
|
catch (Throwable ignored) { DebugLog.error("Failed to save config for module %s", ignored, m.name()); } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import dev.loki.lovisual.render.engine.postprocess.post.PostProcessPass;
|
||||||
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
||||||
import dev.loki.lovisual.render.engine.uniform.impl.post.PostProcessUniforms;
|
import dev.loki.lovisual.render.engine.uniform.impl.post.PostProcessUniforms;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "damagetint", displayName = "DamageTint", category = ModuleCategory.COMBAT)
|
@ModuleInfo(id = "damagetint", displayName = "DamageTint", category = ModuleCategory.COMBAT)
|
||||||
public class DamageTint extends Module implements PostProcessPass {
|
public class DamageTint extends Module implements PostProcessPass {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetE
|
||||||
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.setCaptureSpeedScale;
|
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.setCaptureSpeedScale;
|
||||||
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.tickTargetEsp;
|
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes.tickTargetEsp;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "targetesp", displayName = "TargetESP", aliases = {"target", "targetrender"}, category = ModuleCategory.COMBAT)
|
@ModuleInfo(id = "targetesp", displayName = "TargetESP", aliases = {"target", "targetrender"}, category = ModuleCategory.COMBAT)
|
||||||
public class TargetESP extends Module {
|
public class TargetESP extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import dev.loki.lovisual.features.gui.clickgui.settings.cooldown.CooldownRulesSe
|
||||||
/**
|
/**
|
||||||
* Handles PvP cooldown rendering and optional local rule synthesis.
|
* Handles PvP cooldown rendering and optional local rule synthesis.
|
||||||
*/
|
*/
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "pvpcooldowns",
|
id = "pvpcooldowns",
|
||||||
displayName = "PvpCooldowns",
|
displayName = "PvpCooldowns",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import dev.loki.lovisual.features.module.core.Module;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "spearassist",
|
id = "spearassist",
|
||||||
displayName = "SpearAssist",
|
displayName = "SpearAssist",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
import dev.loki.lovisual.util.player.interaction.NetworkStatsUtil;
|
import dev.loki.lovisual.util.player.interaction.NetworkStatsUtil;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "tpssync",
|
id = "tpssync",
|
||||||
displayName = "TPSSync",
|
displayName = "TPSSync",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import dev.loki.lovisual.util.text.mask.ChatNameUtil;
|
||||||
* - Players: friend/enemy/staff
|
* - Players: friend/enemy/staff
|
||||||
* - Non-players: toggle ignored entities (EntityFilters)
|
* - Non-players: toggle ignored entities (EntityFilters)
|
||||||
*/
|
*/
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "definetarget", displayName = "DefineTarget", aliases = {"clickfriend"}, category = ModuleCategory.MISC)
|
@ModuleInfo(id = "definetarget", displayName = "DefineTarget", aliases = {"clickfriend"}, category = ModuleCategory.MISC)
|
||||||
public class DefineTarget extends Module {
|
public class DefineTarget extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "fakeplayer",
|
id = "fakeplayer",
|
||||||
displayName = "FakePlayer",
|
displayName = "FakePlayer",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "message_filter",
|
id = "message_filter",
|
||||||
displayName = "MessageFilter",
|
displayName = "MessageFilter",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import dev.loki.lovisual.util.wav.engine.CustomSoundEngine;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "hitsounds",
|
id = "hitsounds",
|
||||||
displayName = "HitSounds",
|
displayName = "HitSounds",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "nosound",
|
id = "nosound",
|
||||||
displayName = "NoSound",
|
displayName = "NoSound",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import dev.loki.lovisual.features.module.core.Module;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "betterminecraft",
|
id = "betterminecraft",
|
||||||
displayName = "BetterMinecraft",
|
displayName = "BetterMinecraft",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor;
|
import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor;
|
||||||
import dev.loki.lovisual.mixins.accessors.inventory.PlayerInventoryAccessor;
|
import dev.loki.lovisual.mixins.accessors.inventory.PlayerInventoryAccessor;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "autotool",
|
id = "autotool",
|
||||||
displayName = "AutoTool",
|
displayName = "AutoTool",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
import dev.loki.lovisual.mixins.accessors.inventory.PlayerInventoryAccessor;
|
import dev.loki.lovisual.mixins.accessors.inventory.PlayerInventoryAccessor;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "clickpearl",
|
id = "clickpearl",
|
||||||
displayName = "ClickPearl",
|
displayName = "ClickPearl",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||||
import dev.loki.lovisual.features.module.core.Module;
|
import dev.loki.lovisual.features.module.core.Module;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "nointeract",
|
id = "nointeract",
|
||||||
displayName = "NoInteract",
|
displayName = "NoInteract",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "fovcontrol",
|
id = "fovcontrol",
|
||||||
displayName = "FovControl",
|
displayName = "FovControl",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "aspectratio",
|
id = "aspectratio",
|
||||||
displayName = "AspectRatio",
|
displayName = "AspectRatio",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import dev.loki.lovisual.features.module.core.Module;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "cameraclip",
|
id = "cameraclip",
|
||||||
displayName = "CameraClip",
|
displayName = "CameraClip",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import dev.loki.lovisual.features.module.modules.visuals.player.FullBright;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "freecam", displayName = "Freecam", aliases = {"camera", "spectator"}, category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "freecam", displayName = "Freecam", aliases = {"camera", "spectator"}, category = ModuleCategory.VISUALS)
|
||||||
public class Freecam extends Module {
|
public class Freecam extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import dev.loki.lovisual.features.module.modules.visuals.camera.swing.SwingAnima
|
||||||
/**
|
/**
|
||||||
* ViewModel: mini items + custom swing animations
|
* ViewModel: mini items + custom swing animations
|
||||||
*/
|
*/
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "viewmodel", displayName = "ViewModel", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "viewmodel", displayName = "ViewModel", category = ModuleCategory.VISUALS)
|
||||||
public class ViewModel extends Module {
|
public class ViewModel extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import dev.loki.lovisual.features.module.modules.visuals.effects.ambient.geometr
|
||||||
import dev.loki.lovisual.features.module.modules.visuals.effects.ambient.geometry.WorldParticlesSphere;
|
import dev.loki.lovisual.features.module.modules.visuals.effects.ambient.geometry.WorldParticlesSphere;
|
||||||
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
|
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "worldparticles",
|
id = "worldparticles",
|
||||||
displayName = "WorldParticles",
|
displayName = "WorldParticles",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import net.minecraft.world.phys.Vec3;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "killeffect", displayName = "KillEffect", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "killeffect", displayName = "KillEffect", category = ModuleCategory.VISUALS)
|
||||||
public class KillEffect extends DeathEffectSettings {
|
public class KillEffect extends DeathEffectSettings {
|
||||||
private static final Identifier ORTHODOX_SOUND =
|
private static final Identifier ORTHODOX_SOUND =
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "esp", displayName = "ESP", aliases = {"wallhack", "wh", "outline"}, category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "esp", displayName = "ESP", aliases = {"wallhack", "wh", "outline"}, category = ModuleCategory.VISUALS)
|
||||||
public class ESP extends Module {
|
public class ESP extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import dev.loki.lovisual.render.helpers.clip.ScreenProjection;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "soundesp", displayName = "SoundESP", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "soundesp", displayName = "SoundESP", category = ModuleCategory.VISUALS)
|
||||||
public class SoundESP extends Module {
|
public class SoundESP extends Module {
|
||||||
private static final String SETTING_NAME_MODE = "name_mode";
|
private static final String SETTING_NAME_MODE = "name_mode";
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import dev.loki.lovisual.util.item.food.IllegalItemUtil;
|
||||||
import dev.loki.lovisual.util.item.rarity.RarityColorUtil;
|
import dev.loki.lovisual.util.item.rarity.RarityColorUtil;
|
||||||
import dev.loki.lovisual.util.item.enchant.TopEnchantUtil;
|
import dev.loki.lovisual.util.item.enchant.TopEnchantUtil;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "dropesp", displayName = "DropESP", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "dropesp", displayName = "DropESP", category = ModuleCategory.VISUALS)
|
||||||
public class DropESP extends Module {
|
public class DropESP extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
||||||
import dev.loki.lovisual.render.engine.uniform.impl.post.MotionBlurUniforms;
|
import dev.loki.lovisual.render.engine.uniform.impl.post.MotionBlurUniforms;
|
||||||
import dev.loki.lovisual.util.logging.DebugLog;
|
import dev.loki.lovisual.util.logging.DebugLog;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "motionblur",
|
id = "motionblur",
|
||||||
displayName = "MotionBlur",
|
displayName = "MotionBlur",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import dev.loki.lovisual.render.engine.postprocess.post.PostProcessPass;
|
||||||
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
||||||
import dev.loki.lovisual.render.engine.uniform.impl.post.PostFXUniforms;
|
import dev.loki.lovisual.render.engine.uniform.impl.post.PostFXUniforms;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "postfx", displayName = "PostFX", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "postfx", displayName = "PostFX", category = ModuleCategory.VISUALS)
|
||||||
public class PostFX extends Module implements PostProcessPass {
|
public class PostFX extends Module implements PostProcessPass {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import dev.loki.lovisual.render.engine.postprocess.post.PostProcessPass;
|
||||||
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
||||||
import dev.loki.lovisual.render.engine.uniform.impl.world.HeatUniforms;
|
import dev.loki.lovisual.render.engine.uniform.impl.world.HeatUniforms;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "totemfx", displayName = "TotemFX", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "totemfx", displayName = "TotemFX", category = ModuleCategory.VISUALS)
|
||||||
public class TotemFX extends Module implements PostProcessPass {
|
public class TotemFX extends Module implements PostProcessPass {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import net.minecraft.util.Mth;
|
||||||
import net.minecraft.world.entity.player.Player;
|
import net.minecraft.world.entity.player.Player;
|
||||||
import net.minecraft.world.phys.Vec3;
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "backsword", displayName = "BackSword", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "backsword", displayName = "BackSword", category = ModuleCategory.VISUALS)
|
||||||
public class BackSword extends Module {
|
public class BackSword extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "nametags", displayName = "NameTags", aliases = {"nametag", "tags", "names"}, category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "nametags", displayName = "NameTags", aliases = {"nametag", "tags", "names"}, category = ModuleCategory.VISUALS)
|
||||||
public class NameTags extends Module {
|
public class NameTags extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import dev.loki.lovisual.features.module.core.Module;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "fullbright",
|
id = "fullbright",
|
||||||
displayName = "FullBright",
|
displayName = "FullBright",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
import dev.loki.lovisual.features.module.phase.WorldPhase;
|
import dev.loki.lovisual.features.module.phase.WorldPhase;
|
||||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "blockhighlight",
|
id = "blockhighlight",
|
||||||
displayName = "BlockHighlight",
|
displayName = "BlockHighlight",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(
|
@ModuleInfo(
|
||||||
id = "norender",
|
id = "norender",
|
||||||
displayName = "NoRender",
|
displayName = "NoRender",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import dev.loki.lovisual.render.engine.RenderState;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "worldtweaks", displayName = "WorldTweaks", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "worldtweaks", displayName = "WorldTweaks", category = ModuleCategory.VISUALS)
|
||||||
public class WorldTweaks extends Module {
|
public class WorldTweaks extends Module {
|
||||||
|
|
||||||
|
|
@ -217,7 +216,6 @@ public class WorldTweaks extends Module {
|
||||||
return isEnabled() && changeTime.get();
|
return isEnabled() && changeTime.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public int getTimeOfDayTicks() {
|
public int getTimeOfDayTicks() {
|
||||||
if (syncSystemTime.get()) {
|
if (syncSystemTime.get()) {
|
||||||
return getSystemTimeTicks();
|
return getSystemTimeTicks();
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import dev.loki.lovisual.render.engine.postprocess.render.SkyShaderStarryRendere
|
||||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||||
import net.minecraft.client.Minecraft;
|
import net.minecraft.client.Minecraft;
|
||||||
|
|
||||||
//todo Description
|
|
||||||
@ModuleInfo(id = "shadersky", displayName = "ShaderSky", category = ModuleCategory.VISUALS)
|
@ModuleInfo(id = "shadersky", displayName = "ShaderSky", category = ModuleCategory.VISUALS)
|
||||||
public class ShaderSky extends Module {
|
public class ShaderSky extends Module {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import net.minecraft.world.effect.MobEffect;
|
||||||
import net.minecraft.world.effect.MobEffectInstance;
|
import net.minecraft.world.effect.MobEffectInstance;
|
||||||
import net.minecraft.world.effect.MobEffects;
|
import net.minecraft.world.effect.MobEffects;
|
||||||
import net.minecraft.world.entity.LivingEntity;
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
import net.minecraft.world.entity.ai.attributes.Attribute;
|
|
||||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
|
||||||
import net.minecraft.world.entity.player.Player;
|
import net.minecraft.world.entity.player.Player;
|
||||||
import net.minecraft.world.item.Item;
|
import net.minecraft.world.item.Item;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
@ -60,13 +58,6 @@ public abstract class LivingEntityMixin {
|
||||||
@Shadow
|
@Shadow
|
||||||
protected abstract float getJumpPower();
|
protected abstract float getJumpPower();
|
||||||
|
|
||||||
@Inject(method = "getAttributeValue", at = @At("RETURN"))
|
|
||||||
private void lovisual$noStunSlownessMovementSpeed(Holder<Attribute> attribute, CallbackInfoReturnable<Double> cir) {
|
|
||||||
if (!attribute.equals(Attributes.MOVEMENT_SPEED)) return;
|
|
||||||
return;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional local cooldown synthesis for servers that lock consumables without sending vanilla cooldown packets.
|
* Optional local cooldown synthesis for servers that lock consumables without sending vanilla cooldown packets.
|
||||||
* This never cancels item use; it only starts a visible local timer when explicitly configured.
|
* This never cancels item use; it only starts a visible local timer when explicitly configured.
|
||||||
|
|
@ -165,14 +156,6 @@ public abstract class LivingEntityMixin {
|
||||||
return Math.max(serverDelta, 0.0f);
|
return Math.max(serverDelta, 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Local totem cooldowns are handled from the confirmed entity-status packet when explicitly configured.
|
|
||||||
*/
|
|
||||||
@Inject(method = "checkTotemDeathProtection", at = @At("RETURN"))
|
|
||||||
private void lovisual$onTotemUse(DamageSource source, CallbackInfoReturnable<Boolean> cir) {
|
|
||||||
// no local use blocking or prediction here
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сброс кулдаунов при смерти.
|
* Сброс кулдаунов при смерти.
|
||||||
*/
|
*/
|
||||||
|
|
@ -208,11 +191,6 @@ public abstract class LivingEntityMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Inject(method = "aiStep", at = @At("HEAD"))
|
|
||||||
private void preventCrash(CallbackInfo ci) {
|
|
||||||
// ElytraFly module removed; no crash-prevention override remains.
|
|
||||||
}
|
|
||||||
|
|
||||||
@ModifyExpressionValue(
|
@ModifyExpressionValue(
|
||||||
method = "updateFallFlyingMovement",
|
method = "updateFallFlyingMovement",
|
||||||
at = @At(
|
at = @At(
|
||||||
|
|
@ -367,13 +345,4 @@ public abstract class LivingEntityMixin {
|
||||||
|| eff == MobEffects.NAUSEA.value();
|
|| eff == MobEffects.NAUSEA.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Inject(
|
|
||||||
method = "getBlockSpeedFactor",
|
|
||||||
at = @At("RETURN"),
|
|
||||||
cancellable = true
|
|
||||||
)
|
|
||||||
private void nostun$soulSandVelocity(CallbackInfoReturnable<Float> cir) {
|
|
||||||
// NoStun module removed; no velocity override remains.
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,9 @@ import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||||
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
||||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||||
import dev.loki.lovisual.util.aiming.features.MovementCorrection;
|
import dev.loki.lovisual.util.aiming.features.MovementCorrection;
|
||||||
import dev.loki.lovisual.mixins.input.mouse.InputMixin;
|
|
||||||
|
|
||||||
@Mixin(KeyboardInput.class)
|
@Mixin(KeyboardInput.class)
|
||||||
public abstract class KeyboardInputMixin extends InputMixin {
|
public abstract class KeyboardInputMixin {
|
||||||
|
|
||||||
@ModifyExpressionValue(
|
@ModifyExpressionValue(
|
||||||
method = "tick",
|
method = "tick",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package dev.loki.lovisual.mixins.input.mouse;
|
|
||||||
|
|
||||||
import net.minecraft.client.player.ClientInput;
|
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
|
||||||
import org.spongepowered.asm.mixin.injection.At;
|
|
||||||
import org.spongepowered.asm.mixin.injection.Inject;
|
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
|
||||||
|
|
||||||
@Mixin(ClientInput.class)
|
|
||||||
public abstract class InputMixin {
|
|
||||||
|
|
||||||
@Inject(method = "hasForwardImpulse", at = @At("HEAD"), cancellable = true)
|
|
||||||
private void allowAllDirections(CallbackInfoReturnable<Boolean> cir) {
|
|
||||||
// Sprint module removed; keep vanilla forward impulse check.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -84,16 +84,6 @@ public class MultiPlayerGameModeMixin {
|
||||||
cooldowns.tryStartLocalCooldown(stack.getItem(), ItemCooldownRulesValue.Trigger.INTERACT_ACCEPT);
|
cooldowns.tryStartLocalCooldown(stack.getItem(), ItemCooldownRulesValue.Trigger.INTERACT_ACCEPT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Inject(method = "useItemOn", at = @At("HEAD"))
|
|
||||||
private void onInteractBlockHead(LocalPlayer player, InteractionHand hand, BlockHitResult hit, CallbackInfoReturnable<InteractionResult> cir) {
|
|
||||||
// no PvP cooldown block on interactBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
@Inject(method = "useItemOn", at = @At("RETURN"))
|
|
||||||
private void onInteractBlockReturn(LocalPlayer player, InteractionHand hand, BlockHitResult hit, CallbackInfoReturnable<InteractionResult> cir) {
|
|
||||||
// no PvP cooldown prediction on interactBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
@Inject(method = "useItemOn", at = @At("HEAD"), cancellable = true)
|
@Inject(method = "useItemOn", at = @At("HEAD"), cancellable = true)
|
||||||
private void freecam$block(LocalPlayer player, InteractionHand hand, BlockHitResult hit,
|
private void freecam$block(LocalPlayer player, InteractionHand hand, BlockHitResult hit,
|
||||||
CallbackInfoReturnable<InteractionResult> cir) {
|
CallbackInfoReturnable<InteractionResult> cir) {
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@ import dev.loki.lovisual.render.helpers.util.TickDelta;
|
||||||
import dev.loki.lovisual.util.combat.tracking.AntiBotTracker;
|
import dev.loki.lovisual.util.combat.tracking.AntiBotTracker;
|
||||||
import dev.loki.lovisual.util.session.SessionChanger;
|
import dev.loki.lovisual.util.session.SessionChanger;
|
||||||
import dev.loki.lovisual.util.session.MinecraftGameConfigHolder;
|
import dev.loki.lovisual.util.session.MinecraftGameConfigHolder;
|
||||||
import dev.loki.lovisual.features.module.modules.combat.pvp.PvpCooldowns;
|
|
||||||
import dev.loki.lovisual.features.module.modules.combat.pvp.SpearAssist;
|
|
||||||
|
|
||||||
@Mixin(Minecraft.class)
|
@Mixin(Minecraft.class)
|
||||||
public class MinecraftMixin implements MinecraftGameConfigHolder {
|
public class MinecraftMixin implements MinecraftGameConfigHolder {
|
||||||
|
|
@ -118,16 +116,6 @@ public class MinecraftMixin implements MinecraftGameConfigHolder {
|
||||||
|
|
||||||
// === КУРСОР ДЛЯ CLICKGUI ===
|
// === КУРСОР ДЛЯ CLICKGUI ===
|
||||||
|
|
||||||
@Inject(method = "continueAttack", at = @At("HEAD"), cancellable = true)
|
|
||||||
private void onHandleBlockBreaking(boolean breaking, CallbackInfo ci) {
|
|
||||||
// AutoAttack / Hitbox modules removed; no block-breaking override remains.
|
|
||||||
}
|
|
||||||
|
|
||||||
@Inject(method = "startAttack", at = @At("HEAD"), cancellable = true)
|
|
||||||
private void onDoAttack(CallbackInfoReturnable<Boolean> cir) {
|
|
||||||
// AttributeSwap / AutoAttack modules removed; no attack override remains.
|
|
||||||
}
|
|
||||||
|
|
||||||
@Inject(method = "disconnect(Lnet/minecraft/client/gui/screens/Screen;ZZ)V", at = @At("HEAD"))
|
@Inject(method = "disconnect(Lnet/minecraft/client/gui/screens/Screen;ZZ)V", at = @At("HEAD"))
|
||||||
private void lovisual$betterChat$saveOnDisconnect(net.minecraft.client.gui.screens.Screen screen, boolean transferring, boolean bl, CallbackInfo ci) {
|
private void lovisual$betterChat$saveOnDisconnect(net.minecraft.client.gui.screens.Screen screen, boolean transferring, boolean bl, CallbackInfo ci) {
|
||||||
lovisual$guardSingleplayerSaveWithAltUsername();
|
lovisual$guardSingleplayerSaveWithAltUsername();
|
||||||
|
|
@ -319,11 +307,6 @@ public class MinecraftMixin implements MinecraftGameConfigHolder {
|
||||||
return Mth.clamp(base * 2, 120, 360);
|
return Mth.clamp(base * 2, 120, 360);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Inject(method = "getTickTargetMillis", at = @At("RETURN"), cancellable = true)
|
|
||||||
private void lovisual$applyTimer(float millis, CallbackInfoReturnable<Float> cir) {
|
|
||||||
// Timer module removed; tick speed remains unchanged.
|
|
||||||
}
|
|
||||||
|
|
||||||
@Inject(method = "isLevelRunningNormally", at = @At("HEAD"), cancellable = true)
|
@Inject(method = "isLevelRunningNormally", at = @At("HEAD"), cancellable = true)
|
||||||
private void lovisual$skipPlayerDependentTicksWithoutPlayer(CallbackInfoReturnable<Boolean> cir) {
|
private void lovisual$skipPlayerDependentTicksWithoutPlayer(CallbackInfoReturnable<Boolean> cir) {
|
||||||
Minecraft mc = Minecraft.getInstance();
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,6 @@
|
||||||
"input.keyboard.KeyMappingMixin",
|
"input.keyboard.KeyMappingMixin",
|
||||||
"input.keyboard.KeyboardInputMixin",
|
"input.keyboard.KeyboardInputMixin",
|
||||||
"input.keyboard.KeyboardMixin",
|
"input.keyboard.KeyboardMixin",
|
||||||
"input.mouse.InputMixin",
|
|
||||||
"input.mouse.MouseMixin",
|
"input.mouse.MouseMixin",
|
||||||
"input.spatialgui.MouseHandlerSpatialGuiMixin",
|
"input.spatialgui.MouseHandlerSpatialGuiMixin",
|
||||||
"iris.hand.IrisHandRendererAccessor",
|
"iris.hand.IrisHandRendererAccessor",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue