├── src ├── api │ ├── figura │ │ ├── types │ │ │ ├── mod.rs │ │ │ └── auth.rs │ │ ├── websocket │ │ │ ├── mod.rs │ │ │ ├── types │ │ │ │ ├── session.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── errors.rs │ │ │ │ ├── c2s.rs │ │ │ │ └── s2c.rs │ │ │ └── handler.rs │ │ ├── mod.rs │ │ ├── info.rs │ │ ├── assets.rs │ │ ├── auth.rs │ │ └── profile.rs │ ├── mod.rs │ ├── sculptor │ │ ├── mod.rs │ │ ├── avatars.rs │ │ ├── users.rs │ │ └── http2ws.rs │ └── errors.rs ├── auth │ ├── mod.rs │ ├── types.rs │ └── auth.rs ├── state │ ├── mod.rs │ ├── state.rs │ └── config.rs ├── utils │ ├── mod.rs │ ├── motd.rs │ ├── auxiliary.rs │ └── check_updates.rs ├── consts.rs ├── metrics.rs └── main.rs ├── .gitignore ├── .github ├── release.yml ├── actions │ ├── dependencies │ │ └── action.yml │ └── build │ │ └── action.yml ├── scripts │ └── package-artifacts.sh └── workflows │ ├── ci.yml │ └── release.yml ├── CREDITS ├── Cargo.toml ├── docker-compose.example.yml ├── note.txt ├── Dockerfile ├── Config.example.toml ├── README.md ├── README.ru.md └── LICENSE /src/api/figura/types/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod figura; 2 | pub mod sculptor; 3 | pub mod errors; -------------------------------------------------------------------------------- /src/auth/mod.rs: -------------------------------------------------------------------------------- 1 | mod auth; 2 | mod types; 3 | 4 | pub use auth::*; 5 | pub use types::*; 6 | -------------------------------------------------------------------------------- /src/state/mod.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod state; 3 | 4 | pub use config::*; 5 | pub use state::*; -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod auxiliary; 2 | mod check_updates; 3 | mod motd; 4 | 5 | pub use auxiliary::*; 6 | pub use motd::*; 7 | pub use check_updates::*; -------------------------------------------------------------------------------- /src/api/figura/websocket/mod.rs: -------------------------------------------------------------------------------- 1 | // mod websocket; 2 | mod handler; 3 | mod types; 4 | 5 | // pub use websocket::*; 6 | pub use handler::initial; 7 | pub use types::*; -------------------------------------------------------------------------------- /src/api/figura/mod.rs: -------------------------------------------------------------------------------- 1 | mod types; 2 | mod websocket; 3 | pub mod auth; 4 | pub mod profile; 5 | pub mod info; 6 | pub mod assets; 7 | 8 | pub use websocket::{initial as ws, SessionMessage}; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Assets-main 3 | /avatars 4 | /logs 5 | /assets 6 | /data 7 | output.log 8 | docker-compose.yml 9 | Config.toml 10 | config.toml 11 | .env 12 | perf.data* 13 | banned-players.json -------------------------------------------------------------------------------- /src/api/figura/types/auth.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Deserialize)] 4 | pub struct Id { 5 | pub username: String, 6 | } 7 | 8 | #[derive(Deserialize)] 9 | pub struct Verify { 10 | pub id: String, 11 | } -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: Breaking Changes 🛠 7 | labels: 8 | - breaking-change 9 | - title: New Features 🎉 10 | labels: 11 | - enhancement 12 | - title: Bug fixes 🐛 13 | labels: 14 | - bug 15 | - title: Other Changes 🔄 16 | labels: 17 | - "*" -------------------------------------------------------------------------------- /.github/actions/dependencies/action.yml: -------------------------------------------------------------------------------- 1 | name: Install dependencies 2 | description: Installs Zig and cargo-zigbuild 3 | 4 | inputs: 5 | zig-version: 6 | description: Version of Zig to install 7 | 8 | runs: 9 | using: composite 10 | steps: 11 | 12 | - name: Install Zig 13 | uses: mlugg/setup-zig@v2 14 | with: 15 | version: ${{ inputs.zig-version }} 16 | 17 | - name: Install cargo-zigbuild 18 | shell: sh 19 | run: cargo install cargo-zigbuild -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Many thanks to: 2 | PoolloverNathan (https://github.com/PoolloverNathan) 3 | Martinz64 (https://github.com/Martinz64) 4 | for their work on which Sculptor was developed. 5 | 6 | IntelMiner (https://github.com/IntelMiner) 7 | for bug hunting and feature suggestions. 8 | 9 | Contributors: 10 | korewaChino (https://github.com/korewaChino) 11 | 12 | papaj-na-wrotkach (https://github.com/papaj-na-wrotkach) 13 | for his awesome work on reworking and rethinking the build system. <3 14 | -------------------------------------------------------------------------------- /src/api/figura/websocket/types/session.rs: -------------------------------------------------------------------------------- 1 | use dashmap::DashMap; 2 | use tokio::{sync::{broadcast, mpsc}, task::AbortHandle}; 3 | 4 | pub struct WSSession { 5 | pub user: crate::auth::Userinfo, 6 | pub own_tx: mpsc::Sender, 7 | pub own_rx: mpsc::Receiver, 8 | pub subs_tx: broadcast::Sender>, 9 | pub sub_workers_aborthandles: DashMap, 10 | } 11 | 12 | pub enum SessionMessage { 13 | Ping(Vec), 14 | Banned, 15 | } -------------------------------------------------------------------------------- /src/state/state.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use dashmap::DashMap; 4 | use tokio::{sync::*, time::Instant}; 5 | use uuid::Uuid; 6 | 7 | use crate::{api::figura::SessionMessage, auth::UManager, FiguraVersions}; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct AppState { 11 | /// Uptime 12 | pub uptime: Instant, 13 | /// User manager 14 | pub user_manager: Arc, 15 | /// Send into WebSocket 16 | pub session: Arc>>, 17 | /// Send messages for subscribers 18 | pub subscribes: Arc>>>, 19 | /// Current configuration 20 | pub config: Arc>, 21 | /// Caching Figura Versions 22 | pub figura_versions: Arc>>, 23 | } -------------------------------------------------------------------------------- /src/api/sculptor/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::DefaultBodyLimit, routing::{delete, get, post, put}, Router}; 2 | use crate::AppState; 3 | 4 | mod http2ws; 5 | mod users; 6 | mod avatars; 7 | 8 | pub fn router(limit: usize) -> Router { 9 | Router::new() 10 | .route("/verify", get(http2ws::verify)) 11 | .route("/raw", post(http2ws::raw)) 12 | .route("/sub/raw", post(http2ws::sub_raw)) 13 | .route("/user/list", get(users::list)) 14 | .route("/user/sessions", get(users::list_sessions)) 15 | .route("/user/create", post(users::create_user)) 16 | .route("/user/{uuid}/ban", post(users::ban)) 17 | .route("/user/{uuid}/unban", post(users::unban)) 18 | .route("/avatar/{uuid}", put(avatars::upload_avatar).layer(DefaultBodyLimit::max(limit))) 19 | .route("/avatar/{uuid}", delete(avatars::delete_avatar)) 20 | } -------------------------------------------------------------------------------- /src/consts.rs: -------------------------------------------------------------------------------- 1 | // Environment 2 | pub const LOGGER_ENV: &str = "RUST_LOG"; 3 | pub const CONFIG_ENV: &str = "RUST_CONFIG"; 4 | pub const LOGS_ENV: &str = "LOGS_FOLDER"; 5 | pub const ASSETS_ENV: &str = "ASSETS_FOLDER"; 6 | pub const AVATARS_ENV: &str = "AVATARS_FOLDER"; 7 | 8 | // Instance info 9 | pub const SCULPTOR_VERSION: &str = env!("CARGO_PKG_VERSION"); 10 | pub const REPOSITORY: &str = "shiroyashik/sculptor"; 11 | 12 | // reqwest parameters 13 | pub const USER_AGENT: &str = "reqwest"; 14 | pub const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); 15 | 16 | // Figura update checker 17 | pub const FIGURA_RELEASES_URL: &str = "https://api.github.com/repos/figuramc/figura/releases"; 18 | pub const FIGURA_DEFAULT_VERSION: &str = "0.1.5"; 19 | 20 | // Figura Assets 21 | pub const FIGURA_ASSETS_ZIP_URL: &str = "https://github.com/FiguraMC/Assets/archive/refs/heads/main.zip"; 22 | pub const FIGURA_ASSETS_COMMIT_URL: &str = "https://api.github.com/repos/FiguraMC/Assets/commits/main"; -------------------------------------------------------------------------------- /src/api/figura/websocket/types/mod.rs: -------------------------------------------------------------------------------- 1 | mod c2s; 2 | mod s2c; 3 | mod errors; 4 | mod session; 5 | 6 | use std::time::Instant; 7 | 8 | pub use session::*; 9 | pub use errors::*; 10 | pub use c2s::*; 11 | pub use s2c::*; 12 | 13 | use axum::extract::ws::{Message, WebSocket}; 14 | 15 | use crate::{PINGS, PINGS_ERROR}; 16 | 17 | pub trait RecvAndDecode { 18 | async fn recv_and_decode(&mut self) -> Result; 19 | } 20 | 21 | impl RecvAndDecode for WebSocket { 22 | async fn recv_and_decode(&mut self) -> Result { 23 | let msg = self.recv().await.ok_or(RADError::StreamClosed)??; 24 | 25 | if let Message::Close(frame) = msg { 26 | return Err(RADError::Close(frame.map(|f| format!("code: {}, reason: {}", f.code, f.reason)))); 27 | } 28 | 29 | let start = Instant::now(); 30 | 31 | let data = msg.into_data(); 32 | let msg = C2SMessage::try_from(data.as_ref()) 33 | .map_err(|e| { PINGS_ERROR.inc(); RADError::DecodeError(e, faster_hex::hex_string(&data)) }); 34 | 35 | let latency = start.elapsed().as_secs_f64(); 36 | PINGS 37 | .with_label_values(&[msg.as_ref().map(|m| m.name()).unwrap_or("error")]) 38 | .observe(latency); 39 | msg 40 | } 41 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sculptor" 3 | authors = ["Shiroyashik "] 4 | version = "0.4.1" 5 | edition = "2024" 6 | publish = false 7 | 8 | [dependencies] 9 | # Logging 10 | tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] } 11 | tracing-appender = "0.2" 12 | tracing-panic = "0.1" 13 | tracing = "0.1" 14 | 15 | # Errors handelers 16 | anyhow = "1.0" 17 | thiserror = "2.0" 18 | chrono = { version = "0.4", features = ["now", "serde"] } 19 | serde = { version = "1.0", features = ["derive"] } 20 | serde_json = "1.0" 21 | toml = "0.8" 22 | 23 | # Other 24 | dashmap = { version = "6.0", features = ["serde"] } 25 | faster-hex = "0.10" 26 | uuid = { version = "1.11", features = ["serde"] } 27 | base64 = "0.22" 28 | reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } 29 | dotenvy = "0.15" 30 | semver = "1.0" 31 | walkdir = "2.5" 32 | indexmap = { version = "2.6", features = ["serde"] } 33 | zip = "4.0" 34 | notify = "8.0" 35 | 36 | # Crypto 37 | ring = "0.17" 38 | rand = "0.9" 39 | 40 | # Web 41 | axum = { version = "0.8", features = ["ws", "macros", "http2"] } 42 | tower-http = { version = "0.6", features = ["trace"] } 43 | tokio = { version = "1.41", features = ["full"] } 44 | prometheus = { version = "0.14", features = ["process"] } -------------------------------------------------------------------------------- /docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | name: sculptor 2 | 3 | services: 4 | sculptor: 5 | # build: . 6 | image: ghcr.io/shiroyashik/sculptor:latest 7 | container_name: sculptor 8 | healthcheck: 9 | test: wget --no-verbose --tries=1 --spider http://sculptor:6665/health || exit 1 10 | interval: 5s 11 | timeout: 3s 12 | retries: 3 13 | start_period: 5s 14 | restart: unless-stopped 15 | volumes: 16 | - ./Config.toml:/app/Config.toml:ro 17 | - ./data:/app/data 18 | - ./logs:/app/logs 19 | # You can specify the path to the server folder 20 | # for Sculptor to use the ban list from it 21 | # - ./minecraft-server:/app/mc 22 | environment: 23 | - RUST_LOG=info 24 | # Set your timezone. https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 25 | - TZ=Europe/Moscow 26 | # ports: 27 | # - 6665 28 | ## Recommended for use with reverse proxy. 29 | # networks: 30 | # - traefik 31 | # labels: 32 | # - traefik.enable=true 33 | # - traefik.http.routers.sculptor.rule=Host(`mc.example.com`) 34 | # - traefik.http.routers.sculptor.entrypoints=websecure, web 35 | # - traefik.http.routers.sculptor.tls=true 36 | # - traefik.http.routers.sculptor.tls.certresolver=production 37 | # networks: 38 | # traefik: 39 | # external: true 40 | -------------------------------------------------------------------------------- /note.txt: -------------------------------------------------------------------------------- 1 | WebSocket close codes from Figura 2 | 1000 Normal Closure 3 | 1001 Going Away 4 | 1002 Protocol Error 5 | 1003 Unsupported Data 6 | 1005 No Status Received 7 | 1006 Abnormal Closure 8 | 1007 Invalid Frame Payload Data 9 | 1008 Policy Violation 10 | 1009 Message Too Big 11 | 1010 Mandatory Ext. 12 | 1011 Internal Error 13 | 1012 Service Restart 14 | 1013 Try Again Later 15 | 1014 Bad Gateway 16 | 1015 TLS Handshake 17 | 3000 Unauthorized 18 | 4000 Re-Auth 19 | 4001 Banned 20 | 4002 Too Many Connections 21 | 22 | Special badges 23 | 1 Разработчик | Figura Developer! 24 | 2 Член персонала официального сервера | Official Figura Discord Staff! 25 | 3 Победитель контеста | Figura contest winner! GG! 26 | 4 Спасибо за поддержку | Thank you for supporting the Figura mod! 27 | 5 Переводчик | Figura mod Translator! 28 | 6 Художник текстур мода | Figura mod Texture Artist! 29 | 30 | Pride badges 31 | 1 Агендерный 32 | 2 Ароэйс 33 | 3 Ароматик 34 | 4 Асексуал 35 | 5 Бигендер 36 | 6 Бисексуал 37 | 7 Демибой 38 | 8 Демигендер 39 | 9 Демигирл 40 | 10 Демироматик 41 | 11 Демисексуал 42 | 12 Инвалидности 43 | 13 Финсексуал 44 | 14 Гей Мужчины 45 | 15 Гендерфай 46 | 16 Гендерфлюид 47 | 17 Гендерквир 48 | 18 Интерсекс 49 | 19 Лесбиянка 50 | 20 Небинар 51 | 21 Пансексуал 52 | 22 Плюрал 53 | 23 Полисексуал 54 | 24 Прайд 55 | 25 Трансгендер 56 | 57 | Toast 58 | 0 Blue (Default) 59 | 1 Yellow (Warning) 60 | 2 Red (Error) 61 | 3 Cookie! (OwO) -------------------------------------------------------------------------------- /src/api/errors.rs: -------------------------------------------------------------------------------- 1 | use axum::{http::StatusCode, response::{IntoResponse, Response}}; 2 | use thiserror::Error; 3 | use tracing::{error, warn}; 4 | 5 | pub type ApiResult = Result; 6 | 7 | #[derive(Error, Debug)] 8 | pub enum ApiError { 9 | #[error("bad request")] 10 | BadRequest, // 400 11 | #[error("unauthorized")] 12 | Unauthorized, // 401 13 | #[error("not found")] 14 | NotFound, // 404 15 | #[error("not acceptable")] 16 | NotAcceptable, // 406 17 | #[error("internal server error")] 18 | Internal, // 500 19 | } 20 | 21 | impl IntoResponse for ApiError { 22 | fn into_response(self) -> Response { 23 | match self { 24 | ApiError::BadRequest => (StatusCode::BAD_REQUEST, "bad request").into_response(), 25 | ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized").into_response(), 26 | ApiError::NotAcceptable=> (StatusCode::NOT_ACCEPTABLE, "not acceptable").into_response(), 27 | ApiError::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(), 28 | ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(), 29 | } 30 | } 31 | } 32 | 33 | pub fn internal_and_log(err: E) -> ApiError { // NOTE: Realize it like a macros? 34 | error!("Internal error: {}", err); 35 | ApiError::Internal 36 | } 37 | 38 | pub fn error_and_log(err: E, error_type: ApiError) -> ApiError { 39 | warn!("{error_type:?}: {}", err); 40 | error_type 41 | } -------------------------------------------------------------------------------- /src/api/sculptor/avatars.rs: -------------------------------------------------------------------------------- 1 | use axum::{body::Bytes, extract::{Path, State}}; 2 | use tokio::{fs, io::{self, BufWriter}}; 3 | use tracing::warn; 4 | use uuid::Uuid; 5 | 6 | use crate::{api::figura::profile::send_event, auth::Token, ApiResult, AppState, AVATARS_VAR}; 7 | 8 | pub async fn upload_avatar( 9 | Path(uuid): Path, 10 | Token(token): Token, 11 | State(state): State, 12 | body: Bytes, 13 | ) -> ApiResult<&'static str> { 14 | let request_data = body; 15 | 16 | state.config.read().await.clone().verify_token(&token)?; 17 | 18 | tracing::info!( 19 | "trying to upload the avatar for {}", 20 | uuid, 21 | ); 22 | 23 | let avatar_file = format!("{}/{}.moon", *AVATARS_VAR, &uuid); 24 | let mut file = BufWriter::new(fs::File::create(&avatar_file).await.unwrap()); 25 | io::copy(&mut request_data.as_ref(), &mut file).await.unwrap(); 26 | send_event(&state, &uuid).await; 27 | 28 | Ok("ok") 29 | } 30 | 31 | pub async fn delete_avatar( 32 | Path(uuid): Path, 33 | Token(token): Token, 34 | State(state): State 35 | ) -> ApiResult<&'static str> { 36 | state.config.read().await.clone().verify_token(&token)?; 37 | 38 | tracing::info!( 39 | "trying to delete the avatar for {}", 40 | uuid, 41 | ); 42 | 43 | let avatar_file = format!("{}/{}.moon", *AVATARS_VAR, &uuid); 44 | match fs::remove_file(avatar_file).await { 45 | Ok(_) => {}, 46 | Err(_) => { 47 | warn!("avatar doesn't exist"); 48 | return Err(crate::ApiError::NotFound) 49 | } 50 | }; 51 | send_event(&state, &uuid).await; 52 | 53 | Ok("ok") 54 | } -------------------------------------------------------------------------------- /src/api/figura/info.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, Json}; 2 | use serde_json::{json, Value}; 3 | use tracing::error; 4 | 5 | use crate::{ 6 | utils::{get_figura_versions, get_motd, FiguraVersions}, AppState, FIGURA_DEFAULT_VERSION 7 | }; 8 | 9 | pub async fn version(State(state): State) -> Json { 10 | let res = state.figura_versions.read().await.clone(); 11 | if let Some(res) = res { 12 | Json(res) 13 | } else { 14 | let actual = get_figura_versions().await; 15 | if let Ok(res) = actual { 16 | let mut stored = state.figura_versions.write().await; 17 | *stored = Some(res); 18 | return Json(stored.clone().unwrap()) 19 | } else { 20 | error!("get_figura_versions: {:?}", actual.unwrap_err()); 21 | } 22 | Json(FiguraVersions { 23 | release: FIGURA_DEFAULT_VERSION.to_string(), 24 | prerelease: FIGURA_DEFAULT_VERSION.to_string() 25 | }) 26 | } 27 | } 28 | 29 | pub async fn motd(State(state): State) -> Json> { 30 | Json(get_motd(state).await) 31 | } 32 | 33 | pub async fn limits(State(state): State) -> Json { 34 | let state = &state.config.read().await.limitations; 35 | Json(json!({ 36 | "rate": { 37 | "pingSize": 1024, 38 | "pingRate": 32, 39 | "equip": 1, 40 | "download": 50, 41 | "upload": 1 42 | }, 43 | "limits": { 44 | "maxAvatarSize": state.max_avatar_size * 1000, 45 | "maxAvatars": state.max_avatars, 46 | "allowedBadges": { 47 | "special": [0,0,0,0,0,0], 48 | "pride": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] 49 | } 50 | } 51 | })) 52 | } 53 | -------------------------------------------------------------------------------- /.github/actions/build/action.yml: -------------------------------------------------------------------------------- 1 | name: Build project 2 | description: Builds the project for specified targets using cargo-zigbuild. 3 | 4 | inputs: 5 | targets: 6 | description: A comma-separated list of Rust targets. 7 | default: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,x86_64-pc-windows-gnu 8 | required: true 9 | lint: 10 | description: A boolean indicating if linting (cargo-fmt, clippy) should be run 11 | default: true 12 | required: true 13 | test: 14 | description: A boolean indicating if tests should be run 15 | default: true 16 | required: true 17 | 18 | runs: 19 | using: composite 20 | steps: 21 | 22 | - name: Convert input targets to Bash array 23 | # read comma-separated list of targets, converts it to 24 | # an array of arguments for cargo-zigbuild like this: 25 | # [ "--target", "", "--target", "", ... ] 26 | shell: bash 27 | run: | 28 | targets=() 29 | while read -r target 30 | do targets+=("--target" "$target") 31 | done < <(tr , '\n' <<<"${{ inputs.targets }}") 32 | declare -p targets > /tmp/targets.sh 33 | 34 | - name: Check with cargo-fmt 35 | if: inputs.lint == true 36 | shell: sh 37 | run: cargo fmt -v --all -- --check 38 | 39 | - name: Run Clippy with cargo-zigbuild 40 | if: inputs.lint == true 41 | shell: bash 42 | run: | 43 | . /tmp/targets.sh 44 | cargo-zigbuild clippy -v --all-targets "${targets[@]}" -- -D warnings 45 | 46 | - name: Build with cargo-zigbuild 47 | shell: bash 48 | run: | 49 | . /tmp/targets.sh 50 | cargo-zigbuild build -v -r --bin sculptor "${targets[@]}" 51 | 52 | - name: Test with cargo-zigbuild 53 | shell: bash 54 | if: inputs.test == true 55 | run: | 56 | . /tmp/targets.sh 57 | cargo-zigbuild test -v -r "${targets[@]}" 58 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ALPINE_VERSION="" 2 | ARG RUST_VERSION="1" 3 | ## Chef 4 | # defaults to rust:1-alpine 5 | FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-alpine${ALPINE_VERSION} AS chef 6 | USER root 7 | RUN apk add --no-cache musl-dev cargo-zigbuild 8 | RUN cargo install --locked cargo-chef 9 | WORKDIR /build 10 | 11 | ## Planner 12 | FROM chef AS planner 13 | COPY Cargo.toml Cargo.lock ./ 14 | COPY src src 15 | RUN cargo chef prepare --recipe-path recipe.json 16 | 17 | ## Builder 18 | FROM chef AS builder 19 | COPY --from=planner /build/recipe.json recipe.json 20 | # Map Docker's TARGETPLATFORM to Rust's build 21 | # target and save the result to a .env file 22 | ARG TARGETPLATFORM 23 | RUN <&2; exit 1;; 28 | esac 29 | echo export CARGO_BUILD_TARGET="${CARGO_BUILD_TARGET}" > /tmp/builder.env 30 | rustup target add "${CARGO_BUILD_TARGET}" 31 | EOT 32 | # Build dependencies - this is the caching Docker layer! 33 | RUN . /tmp/builder.env && \ 34 | cargo chef cook --recipe-path recipe.json --release --zigbuild 35 | # Build application 36 | COPY Cargo.toml Cargo.lock ./ 37 | COPY src src 38 | RUN . /tmp/builder.env && \ 39 | cargo zigbuild -r --bin sculptor && \ 40 | # Link the right output directory to a well known location for easier access when copying to the runtime image 41 | ln -s "$PWD/target/$CARGO_BUILD_TARGET/release" /tmp/build-output 42 | 43 | ## Runtime 44 | FROM alpine:${ALPINE_VERSION:-latest} AS runtime 45 | WORKDIR /app 46 | COPY --from=builder /tmp/build-output/sculptor /app/sculptor 47 | 48 | RUN apk add --no-cache tzdata 49 | ENV TZ=Etc/UTC 50 | 51 | VOLUME [ "/app/data" ] 52 | VOLUME [ "/app/logs" ] 53 | EXPOSE 6665/tcp 54 | 55 | ENTRYPOINT [ "./sculptor" ] 56 | -------------------------------------------------------------------------------- /.github/scripts/package-artifacts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | USAGE="\ 4 | Usage: $0 [-t target]... [-o output_dir] [-h] 5 | -t target add a build target 6 | -o output_dir set output directory for compressed files (default: current directory) 7 | -h Show this help message and exit 8 | 9 | Environment variables (override options): 10 | OUTPUT_DIR output directory for compressed files 11 | CARGO_BUILD_TARGETS comma-separated list of targets 12 | " 13 | targets=() 14 | output_dir= 15 | 16 | while getopts "t:o:h" opt 17 | do 18 | case $opt in 19 | t) targets+=("$OPTARG") ;; 20 | o) output_dir="$OPTARG" ;; 21 | h) echo "$USAGE"; exit 0 ;; 22 | *) echo "Invalid option: ${opt}" >&2; echo "$USAGE"; exit 1 ;; 23 | esac 24 | done 25 | 26 | output_dir="${OUTPUT_DIR:-${output_dir:-.}}" 27 | 28 | if [ "${CARGO_BUILD_TARGETS+set}" ] # if set (might be empty) 29 | then IFS=',' read -ra targets <<< "$CARGO_BUILD_TARGETS" 30 | fi 31 | 32 | compress-artifact() { 33 | local build_dir os arch binary_file common_files output_file 34 | 35 | build_dir="$1" 36 | os="$2" 37 | arch="$3" 38 | 39 | binary_file="${build_dir}/sculptor" 40 | # can be extended to include more files if needed 41 | common_files=("Config.example.toml") 42 | output_file="${output_dir}/sculptor-${os}-${arch}" 43 | 44 | if [ "$2" = "windows" ] 45 | then zip -j "${output_file}.zip" "${binary_file}.exe" "${common_files[@]}" 46 | else tar --transform 's|^.*/||' -czf "${output_file}.tar.gz" "$binary_file" "${common_files[@]}" 47 | fi 48 | } 49 | 50 | for target in "${targets[@]}" 51 | do 52 | build_dir="target/${target}/release" 53 | # add more targets as needed, for now only linux and windows 54 | if [[ "$target" =~ ^([^-]+)(-[^-]+)*-(linux|windows)(-[^-]+)*$ ]] 55 | then 56 | os="${BASH_REMATCH[3]}" 57 | arch="${BASH_REMATCH[1]}" 58 | compress-artifact "$build_dir" "$os" "$arch" 59 | else 60 | echo "ERROR: Invalid target: $target" >&2 61 | exit 1 62 | fi 63 | done -------------------------------------------------------------------------------- /src/auth/types.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use serde::{Deserialize, Serialize}; 3 | use uuid::Uuid; 4 | 5 | #[derive(Debug, Clone, Deserialize, Serialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct Userinfo { 8 | pub uuid: Uuid, 9 | pub nickname: String, 10 | pub rank: String, 11 | pub last_used: String, 12 | pub auth_provider: AuthProvider, 13 | pub token: Option, 14 | pub version: String, 15 | pub banned: bool 16 | } 17 | 18 | impl Default for Userinfo { 19 | fn default() -> Self { 20 | Self { 21 | uuid: Default::default(), 22 | nickname: Default::default(), 23 | rank: "default".to_string(), 24 | last_used: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), 25 | auth_provider: Default::default(), 26 | token: Default::default(), 27 | version: "0.1.4+1.20.1".to_string(), 28 | banned: false 29 | } 30 | } 31 | } 32 | 33 | // new part 34 | 35 | #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] 36 | #[serde(rename_all = "camelCase")] 37 | pub struct AuthProvider { 38 | pub name: String, 39 | pub url: String, 40 | } 41 | 42 | impl Default for AuthProvider { 43 | fn default() -> Self { 44 | Self { 45 | name: "Unknown".to_string(), 46 | url: Default::default() 47 | } 48 | } 49 | } 50 | 51 | impl AuthProvider { 52 | pub fn is_empty(&self) -> bool { 53 | self.name == "Unknown" 54 | } 55 | } 56 | 57 | #[derive(Debug, Clone, PartialEq, Deserialize)] 58 | #[serde(rename_all = "camelCase")] 59 | pub struct AuthProviders(pub Vec); 60 | 61 | pub fn default_authproviders() -> AuthProviders { 62 | AuthProviders(vec![ 63 | AuthProvider { name: "Mojang".to_string(), url: "https://sessionserver.mojang.com/session/minecraft/hasJoined".to_string() }, 64 | AuthProvider { name: "ElyBy".to_string(), url: "https://account.ely.by/api/minecraft/session/hasJoined".to_string() } 65 | ]) 66 | } -------------------------------------------------------------------------------- /src/api/sculptor/users.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, State}, 3 | Json 4 | }; 5 | use dashmap::DashMap; 6 | use tracing::{debug, info}; 7 | use uuid::Uuid; 8 | 9 | use crate::{auth::{Token, Userinfo}, ApiResult, AppState}; 10 | 11 | pub(super) async fn create_user( 12 | Token(token): Token, 13 | State(state): State, 14 | Json(json): Json 15 | ) -> ApiResult<&'static str> { 16 | state.config.read().await.clone().verify_token(&token)?; 17 | 18 | debug!("Creating new user: {json:?}"); 19 | 20 | state.user_manager.insert_user(json.uuid, json); 21 | Ok("ok") 22 | } 23 | 24 | pub(super) async fn ban( 25 | Token(token): Token, 26 | State(state): State, 27 | Path(uuid): Path 28 | ) -> ApiResult<&'static str> { 29 | state.config.read().await.clone().verify_token(&token)?; 30 | 31 | info!("Trying ban user: {uuid}"); 32 | 33 | if let Some(tx) = state.session.get(&uuid) {let _ = tx.send(crate::api::figura::SessionMessage::Banned).await;} 34 | state.user_manager.ban(&Userinfo { uuid, banned: true, ..Default::default() }); 35 | Ok("ok") 36 | } 37 | 38 | pub(super) async fn unban( 39 | Token(token): Token, 40 | State(state): State, 41 | Path(uuid): Path 42 | ) -> ApiResult<&'static str> { 43 | state.config.read().await.clone().verify_token(&token)?; 44 | 45 | info!("Trying unban user: {uuid}"); 46 | 47 | state.user_manager.unban(&uuid); 48 | Ok("ok") 49 | } 50 | 51 | pub(super) async fn list( 52 | Token(token): Token, 53 | State(state): State, 54 | ) -> ApiResult>> { 55 | state.config.read().await.clone().verify_token(&token)?; 56 | 57 | Ok(Json(state.user_manager.get_all_registered())) 58 | } 59 | 60 | pub(super) async fn list_sessions( 61 | Token(token): Token, 62 | State(state): State, 63 | ) -> ApiResult>> { 64 | state.config.read().await.clone().verify_token(&token)?; 65 | 66 | Ok(Json(state.user_manager.get_all_authenticated())) 67 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | paths: 7 | - src/** 8 | - Cargo* 9 | - Dockerfile 10 | # this file 11 | - .github/workflows/ci.yml 12 | pull_request: 13 | branches: [ "master" ] 14 | paths: 15 | - src/** 16 | - Cargo* 17 | - Dockerfile 18 | # this file 19 | - .github/workflows/ci.yml 20 | 21 | permissions: 22 | contents: read 23 | 24 | env: 25 | ZIG_VERSION: 0.14.1 26 | CARGO_TERM_COLOR: always 27 | CARGO_BUILD_TARGETS: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,x86_64-pc-windows-gnu 28 | 29 | jobs: 30 | build: 31 | name: Build, lint and test 32 | runs-on: ubuntu-latest 33 | env: 34 | OUTPUT_DIR: target/output 35 | # in case we wanted to test multiple toolchains: 36 | strategy: 37 | matrix: 38 | toolchain: 39 | - 1.87 40 | # - stable 41 | # - nightly 42 | steps: 43 | 44 | - name: Checkout the code 45 | uses: actions/checkout@v4 46 | 47 | - name: Use build cache 48 | uses: Swatinem/rust-cache@v2 49 | with: 50 | prefix-key: "cargo-v0" 51 | cache-all-crates: true 52 | 53 | - name: Set up Rust toolchain 54 | uses: dtolnay/rust-toolchain@master 55 | with: 56 | toolchain: ${{ matrix.toolchain }} 57 | targets: ${{ env.CARGO_BUILD_TARGETS }} 58 | components: clippy, rustfmt 59 | 60 | - name: Install the build dependencies 61 | uses: ./.github/actions/dependencies 62 | with: 63 | zig-version: ${{ env.ZIG_VERSION }} 64 | 65 | - name: Build the project 66 | uses: ./.github/actions/build 67 | with: 68 | targets: ${{ env.CARGO_BUILD_TARGETS }} 69 | 70 | - name: Create output directory for artifacts 71 | run: mkdir -p "$OUTPUT_DIR" 72 | 73 | - name: Package the artifacts 74 | run: ./.github/scripts/package-artifacts.sh 75 | 76 | - name: Upload the artifacts 77 | uses: actions/upload-artifact@v4 78 | with: 79 | path: ${{ env.OUTPUT_DIR }}/* -------------------------------------------------------------------------------- /src/api/figura/websocket/types/errors.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::*; 2 | use std::ops::RangeInclusive; 3 | 4 | use thiserror::Error; 5 | 6 | #[derive(Debug)] 7 | pub enum MessageLoadError { 8 | BadEnum(&'static str, RangeInclusive, usize), 9 | BadLength(&'static str, usize, bool, usize), 10 | } 11 | impl Display for MessageLoadError { 12 | fn fmt(&self, fmt: &mut Formatter) -> Result { 13 | match self { 14 | Self::BadEnum(f, r, c) => write!( 15 | fmt, 16 | "invalid value of {f}: must be {} to {} inclusive, got {c}", 17 | r.start(), 18 | r.end() 19 | ), 20 | Self::BadLength(f, n, e, c) => write!( 21 | fmt, 22 | "buffer wrong size for {f}: must be {} {n} bytes, got {c}", 23 | if *e { "exactly" } else { "at least" } 24 | ), 25 | } 26 | } 27 | } 28 | 29 | #[derive(Error, Debug)] 30 | pub enum RADError { 31 | #[error("message decode error due: {0}, invalid data: {1}")] 32 | DecodeError(MessageLoadError, String), 33 | #[error("close, frame: {0:?}")] 34 | Close(Option), 35 | #[error(transparent)] 36 | WebSocketError(#[from] axum::Error), 37 | #[error("stream closed")] 38 | StreamClosed, 39 | } 40 | 41 | #[derive(Error, Debug)] 42 | pub enum AuthModeError { 43 | #[error("token recieve error due {0}")] 44 | RecvError(RADError), 45 | #[error("action attempt without authentication")] 46 | UnauthorizedAction, 47 | #[error("convert error, bytes into string")] 48 | ConvertError, 49 | #[error("can't send, websocket broken")] 50 | SendError, 51 | #[error("authentication failure, sending re-auth...")] 52 | AuthenticationFailure, 53 | #[error("{0} banned")] 54 | Banned(String), 55 | } 56 | 57 | #[cfg(test)] 58 | #[test] 59 | fn message_load_error_display() { 60 | use MessageLoadError::*; 61 | assert_eq!( 62 | BadEnum("foo", 3..=5, 7).to_string(), 63 | "invalid value of foo: must be 3 to 5 inclusive, got 7" 64 | ); 65 | assert_eq!( 66 | BadLength("bar", 17, false, 12).to_string(), 67 | "buffer wrong size for bar: must be at least 17 bytes, got 12" 68 | ); 69 | assert_eq!( 70 | BadLength("bar", 17, true, 19).to_string(), 71 | "buffer wrong size for bar: must be exactly 17 bytes, got 19" 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/utils/motd.rs: -------------------------------------------------------------------------------- 1 | use chrono::Duration; 2 | use serde::{Deserialize, Serialize}; 3 | use tracing::error; 4 | 5 | use crate::AppState; 6 | 7 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct Motd { 10 | pub text: String, 11 | #[serde(skip_serializing_if = "Option::is_none")] 12 | pub color: Option, 13 | #[serde(skip_serializing_if = "Option::is_none")] 14 | pub click_event: Option, 15 | #[serde(skip_serializing_if = "Option::is_none")] 16 | pub underlined: Option, 17 | } 18 | 19 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct ClickEvent { 22 | pub action: String, 23 | pub value: String, 24 | } 25 | 26 | pub async fn get_motd(state: AppState) -> Vec { 27 | let motd_settings = &state.config.read().await.motd; 28 | 29 | let custom: Result, serde_json::Error> = serde_json::from_str(&motd_settings.custom_text).map_err(|e| { error!("Can't parse custom MOTD!\n{e:?}"); e}); 30 | if !motd_settings.display_server_info { 31 | return custom.unwrap(); 32 | } 33 | 34 | // let time = Local::now().format("%H:%M"); 35 | let uptime = state.uptime.elapsed().as_secs(); 36 | let duration = Duration::seconds(uptime.try_into().unwrap()); 37 | let hours = duration.num_hours(); 38 | let minutes = duration.num_minutes() % 60; 39 | let seconds = duration.num_seconds() % 60; 40 | 41 | let mut ser_info = vec![ 42 | // Motd { 43 | // text: format!("Generated at {time}\n"), 44 | // ..Default::default() 45 | // }, 46 | Motd { 47 | text: format!("{}{:02}:{:02}:{:02}\n", motd_settings.text_uptime, hours, minutes, seconds), 48 | ..Default::default() 49 | }, 50 | Motd { 51 | text: format!("{}{}\n", motd_settings.text_authclients, state.user_manager.count_authenticated()), 52 | ..Default::default() 53 | }, 54 | ]; 55 | 56 | if motd_settings.draw_indent { 57 | ser_info.push(Motd { 58 | text: "----\n\n".to_string(), 59 | color: Some("gold".to_string()), 60 | ..Default::default() 61 | }) 62 | } 63 | 64 | if let Ok(custom) = custom { 65 | [ser_info, custom].concat() 66 | } else { 67 | ser_info 68 | } 69 | } -------------------------------------------------------------------------------- /src/api/figura/assets.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use axum::{extract::Path, routing::get, Json, Router}; 4 | use indexmap::IndexMap; 5 | use ring::digest::{digest, SHA256}; 6 | use serde_json::Value; 7 | use tokio::{fs, io::AsyncReadExt as _}; 8 | use walkdir::WalkDir; 9 | 10 | use crate::{api::errors::internal_and_log, ApiError, ApiResult, AppState, ASSETS_VAR}; 11 | 12 | pub fn router() -> Router { 13 | Router::new() 14 | .route("/", get(versions)) 15 | .route("/{version}", get(hashes)) 16 | .route("/{version}/{*path}", get(download)) 17 | } 18 | 19 | async fn versions() -> ApiResult> { 20 | let dir_path = PathBuf::from(&*ASSETS_VAR); 21 | 22 | let mut directories = Vec::new(); 23 | 24 | let mut entries = fs::read_dir(dir_path).await.map_err(internal_and_log)?; 25 | 26 | while let Some(entry) = entries.next_entry().await.map_err(internal_and_log)? { 27 | if entry.metadata().await.map_err(internal_and_log)?.is_dir() { 28 | if let Some(name) = entry.file_name().to_str() { 29 | let name = name.to_string(); 30 | if !name.starts_with('.') { 31 | directories.push(Value::String(name.to_string())); 32 | } 33 | } 34 | } 35 | } 36 | 37 | Ok(Json(serde_json::Value::Array(directories))) 38 | } 39 | 40 | async fn hashes(Path(version): Path) -> ApiResult>> { 41 | let map = index_assets(&version).await.map_err(internal_and_log)?; 42 | Ok(Json(map)) 43 | } 44 | 45 | async fn download(Path((version, path)): Path<(String, String)>) -> ApiResult> { 46 | let mut file = if let Ok(file) = fs::File::open(format!("{}/{version}/{path}", *ASSETS_VAR)).await { 47 | file 48 | } else { 49 | return Err(ApiError::NotFound) 50 | }; 51 | let mut buffer = Vec::new(); 52 | file.read_to_end(&mut buffer).await.map_err(internal_and_log)?; 53 | Ok(buffer) 54 | } 55 | 56 | // non web 57 | 58 | async fn index_assets(version: &str) -> anyhow::Result> { 59 | let mut map = IndexMap::new(); 60 | let version_path = PathBuf::from(&*ASSETS_VAR).join(version); 61 | 62 | for entry in WalkDir::new(version_path.clone()).into_iter().filter_map(|e| e.ok()) { 63 | let data = match fs::read(entry.path()).await { 64 | Ok(d) => d, 65 | Err(_) => continue 66 | }; 67 | 68 | let path: String = if cfg!(windows) { 69 | entry.path().strip_prefix(version_path.clone())?.to_string_lossy().to_string().replace("\\", "/") 70 | } else { 71 | entry.path().strip_prefix(version_path.clone())?.to_string_lossy().to_string() 72 | }; 73 | 74 | map.insert(path, Value::from(faster_hex::hex_string(digest(&SHA256, &data).as_ref()))); 75 | } 76 | 77 | Ok(map) 78 | } -------------------------------------------------------------------------------- /src/metrics.rs: -------------------------------------------------------------------------------- 1 | 2 | use std::{sync::LazyLock, time::Instant}; 3 | 4 | use axum::{body::Body, extract::State, http::{Request, Response}, middleware::Next, routing::get, Router}; 5 | use prometheus::{proto::{Metric, MetricType}, register_histogram_vec, register_int_counter}; 6 | use reqwest::StatusCode; 7 | 8 | use crate::state::AppState; 9 | 10 | pub fn metrics_router(enabled: bool) -> Router { 11 | if !enabled { return Router::new(); } 12 | tracing::info!("Metrics enabled! You can access them on /metrics"); 13 | Router::new() 14 | .route("/metrics", get(metrics)) 15 | } 16 | 17 | async fn metrics(State(state): State) -> String { 18 | let mut metric_families = prometheus::gather(); 19 | 20 | // Add new custom metrics 21 | let players = { 22 | let mut gauge = prometheus::proto::Gauge::default(); 23 | gauge.set_value(state.session.len() as f64); 24 | 25 | let mut metric = prometheus::proto::Metric::default(); 26 | metric.set_gauge(gauge); 27 | create_mf("sculptor_players_count".to_string(), "Number of players".to_string(), MetricType::GAUGE, metric) 28 | }; 29 | 30 | metric_families.push(players); 31 | 32 | prometheus::TextEncoder::new() 33 | .encode_to_string(&metric_families) 34 | .unwrap() 35 | } 36 | 37 | #[inline] 38 | fn create_mf(name: String, help: String, field_type: MetricType, metric: Metric) -> prometheus::proto::MetricFamily { 39 | let mut mf = prometheus::proto::MetricFamily::default(); 40 | mf.set_name(name); 41 | mf.set_help(help); 42 | mf.set_field_type(field_type); 43 | mf.mut_metric().push(metric); 44 | mf 45 | } 46 | 47 | pub async fn track_metrics(req: Request, next: Next) -> Result, StatusCode> { 48 | let method = req.method().to_string(); 49 | let route = http_route(&req).to_string(); 50 | 51 | let start = Instant::now(); 52 | 53 | // Call the next middleware or handler 54 | let response = next.run(req).await; 55 | 56 | let latency = start.elapsed().as_secs_f64(); 57 | 58 | REQUESTS 59 | .with_label_values(&[&method, &route, &String::from(response.status().as_str())]) 60 | .observe(latency); 61 | 62 | Ok(response) 63 | } 64 | 65 | // https://github.com/davidB/tracing-opentelemetry-instrumentation-sdk/blob/main/axum-tracing-opentelemetry/src/middleware/trace_extractor.rs#L177 66 | #[inline] 67 | fn http_route(req: &Request) -> &str { 68 | req.extensions() 69 | .get::() 70 | .map_or_else(|| "", |mp| mp.as_str()) 71 | } 72 | 73 | pub static REQUESTS: LazyLock = LazyLock::new(|| { 74 | register_histogram_vec!("sculptor_requests", "Number of requests", &["method", "route", "code"], vec![0.025, 0.250, 0.500]).unwrap() 75 | }); 76 | 77 | pub static PINGS: LazyLock = LazyLock::new(|| { 78 | register_histogram_vec!("sculptor_pings", "Number of pings", &["type"], vec![0.000001, 0.00001, 0.0001]).unwrap() 79 | }); 80 | 81 | pub static PINGS_ERROR: LazyLock = LazyLock::new(|| { 82 | register_int_counter!("sculptor_pings_error", "Number of ping decoding errors").unwrap() 83 | }); -------------------------------------------------------------------------------- /Config.example.toml: -------------------------------------------------------------------------------- 1 | ## If running in a Docker container, leave this as default. 2 | listen = "0.0.0.0:6665" 3 | 4 | ## Don't touch if you don't know what you're doing 5 | # token = "" 6 | 7 | ## Enable Prometheus metrics 8 | # metricsEnabled = true 9 | 10 | ## Path to minecraft server folder 11 | ## Sculptor try to use ban list from it 12 | ## on Windows use double slash: "C:\\Servers\\1.20.1" 13 | # mcFolder = "~/minecraft_server" 14 | 15 | ## Can't work without at least one provider! 16 | ## If not set, default providers (Mojang, ElyBy) will be provided. 17 | # authProviders = [ 18 | # { name = "Mojang", url = "https://sessionserver.mojang.com/session/minecraft/hasJoined" }, 19 | # { name = "ElyBy", url = "https://account.ely.by/api/minecraft/session/hasJoined" }, 20 | # ] 21 | 22 | ## Enabling Asset Updater. 23 | ## If false, Sculptor will still respond to assets. Sculptor will handle any installed assets. 24 | ## (The path must be ./data/assets unless overridden!) 25 | ## This allows you to modify or create your own assets from scratch. X> 26 | ## Default value = false 27 | assetsUpdaterEnabled = true 28 | 29 | ## Message of The Day 30 | ## It will be displayed to every player in the Figura menu who is connected to your server 31 | [motd] 32 | displayServerInfo = true 33 | sInfoUptime = "Uptime: " 34 | sInfoAuthClients = "Authenticated clients: " 35 | sInfoDrawIndent = true 36 | customText = """ 37 | [ 38 | { 39 | "text": "You are connected to " 40 | }, 41 | { 42 | "color": "gold", 43 | "text": "The Sculptor" 44 | }, 45 | { 46 | "text": "\\nUnofficial Backend V2 for Figura\\n\\n" 47 | }, 48 | { 49 | "clickEvent": { 50 | "action": "open_url", 51 | "value": "https://github.com/shiroyashik/sculptor" 52 | }, 53 | "text": "Please " 54 | }, 55 | { 56 | "clickEvent": { 57 | "action": "open_url", 58 | "value": "https://github.com/shiroyashik/sculptor" 59 | }, 60 | "color": "gold", 61 | "text": "Star", 62 | "underlined": true 63 | }, 64 | { 65 | "clickEvent": { 66 | "action": "open_url", 67 | "value": "https://github.com/shiroyashik/sculptor" 68 | }, 69 | "text": " me on GitHub!\\n\\n" 70 | } 71 | ] 72 | """ 73 | 74 | ## Full update of these parameters occurs only after restarting the Sculptor!!! 75 | [limitations] 76 | maxAvatarSize = 100 # KB 77 | maxAvatars = 10 # It doesn't look like Figura has any actions implemented with this? 78 | ## P.S. And it doesn't look like the current API allows anything like that... 79 | 80 | [advancedUsers.66004548-4de5-49de-bade-9c3933d8eb97] 81 | username = "Shiroyashik" 82 | special = [0,0,0,1,0,0] # 6 83 | pride = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] # 25 84 | 85 | ## With advancedUsers you can set additional parameters 86 | # [advancedUsers.your-uuid-here] 87 | # username = "Your_username_here" 88 | # banned = true 89 | # special = [0,1,0,0,0,0] # Set badges what you want! :D 90 | # pride = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] # Check out note.txt for reference 91 | 92 | ## you can create an unlimited number of "advancedUsers" for any players. -------------------------------------------------------------------------------- /src/api/figura/auth.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::{Query, State}, http::HeaderMap, response::{IntoResponse, Response}, routing::get, Router}; 2 | use reqwest::{header::USER_AGENT, StatusCode}; 3 | use ring::digest::{self, digest}; 4 | use tracing::{error, info, instrument}; 5 | 6 | use crate::{auth::{has_joined, Userinfo}, utils::rand, AppState}; 7 | use super::types::auth::*; 8 | 9 | pub fn router() -> Router { 10 | Router::new() 11 | .route("/id", get(id)) 12 | .route("/verify", get(verify)) 13 | } 14 | 15 | async fn id( 16 | // First stage of authentication 17 | Query(query): Query, 18 | State(state): State, 19 | ) -> String { 20 | let server_id = 21 | faster_hex::hex_string(&digest(&digest::SHA1_FOR_LEGACY_USE_ONLY, &rand()).as_ref()[0..20]); 22 | let state = state.user_manager; 23 | state.pending_insert(server_id.clone(), query.username); 24 | server_id 25 | } 26 | 27 | #[instrument(skip_all)] 28 | async fn verify( 29 | // Second stage of authentication 30 | Query(query): Query, 31 | header: HeaderMap, 32 | State(state): State, 33 | ) -> Response { 34 | let server_id = query.id.clone(); 35 | let nickname = state.user_manager.pending_remove(&server_id).unwrap().1; // TODO: Add error check 36 | let userinfo = match has_joined( 37 | state.config.read().await.auth_providers.clone(), 38 | &server_id, 39 | &nickname 40 | ).await { 41 | Ok(d) => d, 42 | Err(_e) => { 43 | // error!("[Authentication] {e}"); // In auth error log already defined 44 | return (StatusCode::INTERNAL_SERVER_ERROR, "internal verify error".to_string()).into_response(); 45 | }, 46 | }; 47 | if let Some((uuid, auth_provider)) = userinfo { 48 | let umanager = state.user_manager; 49 | if umanager.is_banned(&uuid) { 50 | info!("{nickname} tried to log in, but was banned"); 51 | return (StatusCode::BAD_REQUEST, "You're banned!".to_string()).into_response(); 52 | } 53 | let mut userinfo = Userinfo { 54 | nickname, 55 | uuid, 56 | token: Some(server_id.clone()), 57 | auth_provider, 58 | ..Default::default() 59 | }; 60 | if let Some(agent) = header.get(USER_AGENT) { 61 | if let Ok(agent) = agent.to_str() { 62 | userinfo.version = agent.to_string(); 63 | } 64 | } 65 | info!("{} logged in using {} with {}", userinfo.nickname, userinfo.auth_provider.name, userinfo.version); 66 | 67 | match umanager.insert(uuid, server_id.clone(), userinfo.clone()) { 68 | Ok(_) => {}, 69 | Err(_) => { 70 | umanager.remove(&uuid); 71 | if umanager.insert(uuid, server_id.clone(), userinfo).is_err() { 72 | error!("Old token error after attempting to remove it! Unexpected behavior!"); 73 | return (StatusCode::BAD_REQUEST, "second session detected".to_string()).into_response(); 74 | }; 75 | } 76 | } 77 | (StatusCode::OK, server_id.to_string()).into_response() 78 | } else { 79 | info!("failed to verify {nickname}"); 80 | (StatusCode::BAD_REQUEST, "failed to verify".to_string()).into_response() 81 | } 82 | } -------------------------------------------------------------------------------- /src/api/sculptor/http2ws.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use axum::extract::{Query, State}; 4 | use tracing::instrument; 5 | use uuid::Uuid; 6 | 7 | use crate::{api::errors::{error_and_log, internal_and_log}, auth::Token, ApiResult, AppState}; 8 | 9 | /* 10 | FIXME: need to refactor 11 | */ 12 | 13 | pub(super) async fn verify( 14 | Token(token): Token, 15 | State(state): State, 16 | ) -> ApiResult<&'static str> { 17 | state.config.read().await.clone() 18 | .verify_token(&token)?; 19 | Ok("ok") 20 | } 21 | 22 | #[instrument(skip(token, state, body))] 23 | pub(super) async fn raw( 24 | Token(token): Token, 25 | Query(query): Query>, 26 | State(state): State, 27 | body: String, 28 | ) -> ApiResult<&'static str> { 29 | tracing::trace!(body = body); 30 | state.config.read().await.clone().verify_token(&token)?; 31 | let mut payload = vec![0; body.len() / 2]; 32 | faster_hex::hex_decode(body.as_bytes(), &mut payload).map_err(|err| { tracing::warn!("not raw data"); error_and_log(err, crate::ApiError::NotAcceptable) })?; 33 | 34 | if query.contains_key("uuid") == query.contains_key("all") { 35 | tracing::warn!("invalid query params"); 36 | return Err(crate::ApiError::BadRequest); 37 | } 38 | 39 | if let Some(uuid) = query.get("uuid") { 40 | // for one 41 | let uuid = Uuid::parse_str(uuid).map_err(|err| { tracing::warn!("invalid uuid"); error_and_log(err, crate::ApiError::BadRequest) })?; 42 | let tx = state.session.get(&uuid).ok_or_else(|| { tracing::warn!("unknown uuid"); crate::ApiError::NotFound })?; 43 | tx.value().send(crate::api::figura::SessionMessage::Ping(payload)).await.map_err(internal_and_log)?; 44 | Ok("ok") 45 | } else if query.contains_key("all") { 46 | // for all 47 | for tx in state.session.iter() { 48 | if let Err(e) = tx.value().send(crate::api::figura::SessionMessage::Ping(payload.clone())).await { 49 | tracing::debug!(error = ?e , "error while sending to session"); 50 | } 51 | }; 52 | Ok("ok") 53 | } else { 54 | tracing::error!("unreachable code!"); 55 | Err(crate::ApiError::Internal) 56 | } 57 | } 58 | 59 | #[instrument(skip(token, state, body))] 60 | pub(super) async fn sub_raw( 61 | Token(token): Token, 62 | Query(query): Query>, 63 | State(state): State, 64 | body: String, 65 | ) -> ApiResult<&'static str> { 66 | tracing::trace!(body = body); 67 | state.config.read().await.clone().verify_token(&token)?; 68 | let mut payload = vec![0; body.len() / 2]; 69 | faster_hex::hex_decode(body.as_bytes(), &mut payload).map_err(|err| { tracing::warn!("not raw data"); error_and_log(err, crate::ApiError::NotAcceptable) })?; 70 | 71 | if let Some(uuid) = query.get("uuid") { 72 | let uuid = Uuid::parse_str(uuid).map_err(|err| { tracing::warn!("invalid uuid"); error_and_log(err, crate::ApiError::BadRequest) })?; 73 | let tx = state.subscribes.get(&uuid).ok_or_else(|| { tracing::warn!("unknown uuid"); crate::ApiError::NotFound })?; 74 | tx.value().send(payload).map_err(internal_and_log)?; 75 | Ok("ok") 76 | } else { 77 | tracing::warn!("uuid doesnt defined"); 78 | Err(crate::ApiError::NotFound) 79 | } 80 | } -------------------------------------------------------------------------------- /src/state/config.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, io::Read, path::PathBuf}; 2 | 3 | use serde::Deserialize; 4 | use tracing::{debug, warn}; 5 | use uuid::Uuid; 6 | 7 | use crate::auth::{default_authproviders, AuthProviders, Userinfo}; 8 | 9 | #[derive(Deserialize, Clone, Debug, PartialEq)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct Config { 12 | pub listen: String, 13 | #[serde(default)] 14 | pub metrics_enabled: bool, 15 | pub token: Option, 16 | pub assets_updater_enabled: bool, 17 | pub motd: CMotd, 18 | #[serde(default = "default_authproviders")] 19 | pub auth_providers: AuthProviders, 20 | pub limitations: Limitations, 21 | #[serde(default)] 22 | pub mc_folder: PathBuf, 23 | #[serde(default)] 24 | pub advanced_users: HashMap, 25 | } 26 | 27 | #[derive(Deserialize, Clone, Debug, PartialEq)] 28 | #[serde(rename_all = "camelCase")] 29 | pub struct CMotd { 30 | pub display_server_info: bool, 31 | pub custom_text: String, 32 | #[serde(rename = "sInfoUptime")] 33 | pub text_uptime: String, 34 | #[serde(rename = "sInfoAuthClients")] 35 | pub text_authclients: String, 36 | #[serde(rename = "sInfoDrawIndent")] 37 | pub draw_indent: bool, 38 | } 39 | 40 | #[derive(Deserialize, Clone, Debug, PartialEq)] 41 | #[serde(rename_all = "camelCase")] 42 | pub struct Limitations { 43 | pub max_avatar_size: u64, 44 | pub max_avatars: u64, 45 | } 46 | 47 | #[derive(Deserialize, Clone, Debug, PartialEq)] 48 | #[serde(rename_all = "camelCase")] 49 | pub struct AdvancedUsers { 50 | #[serde(default)] 51 | pub username: String, 52 | #[serde(default)] 53 | pub banned: bool, 54 | #[serde(default)] 55 | pub special: [u8;6], 56 | #[serde(default)] 57 | pub pride: [u8;25], 58 | } 59 | 60 | #[derive(Deserialize, Clone, Debug, PartialEq)] 61 | #[serde(rename_all = "camelCase")] 62 | pub struct BannedPlayer { 63 | pub uuid: Uuid, 64 | pub name: String, 65 | } 66 | 67 | impl From for Userinfo { 68 | fn from(val: BannedPlayer) -> Self { 69 | Userinfo { 70 | uuid: val.uuid, 71 | nickname: val.name, 72 | banned: true, 73 | ..Default::default() 74 | } 75 | } 76 | } 77 | 78 | impl Config { 79 | pub fn parse(path: PathBuf) -> Self { 80 | let mut file = std::fs::File::open(path).expect("Access denied or file doesn't exists!"); 81 | let mut data = String::new(); 82 | file.read_to_string(&mut data).unwrap(); 83 | 84 | toml::from_str(&data).unwrap_or_else(|err| {tracing::error!("{err:#?}"); panic!("Panic occured! See log messages!")}) 85 | } 86 | 87 | pub fn verify_token(&self, suspicious: &str) -> crate::ApiResult<()> { 88 | use crate::ApiError; 89 | match &self.token { 90 | Some(token) => { 91 | if token == suspicious { 92 | debug!("Admin token passed!"); 93 | Ok(()) 94 | } else { 95 | warn!("Unknown tryed to use admin functions, but use wrong token!"); 96 | Err(ApiError::Unauthorized) 97 | } 98 | }, 99 | None => { 100 | warn!("Unknown tryed to use admin functions, but token is not defined!"); 101 | Err(ApiError::BadRequest) 102 | }, 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /src/api/figura/websocket/types/c2s.rs: -------------------------------------------------------------------------------- 1 | use uuid::Uuid; 2 | 3 | use super::MessageLoadError; 4 | use std::convert::{TryFrom, TryInto}; 5 | 6 | #[repr(u8)] 7 | #[derive(Debug, Clone, PartialEq, Eq)] 8 | pub enum C2SMessage { 9 | Token(Vec) = 0, 10 | Ping(u32, bool, Vec) = 1, 11 | Sub(Uuid) = 2, // owo 12 | Unsub(Uuid) = 3, 13 | } 14 | // 6 - 6 15 | impl TryFrom<&[u8]> for C2SMessage { 16 | type Error = MessageLoadError; 17 | fn try_from(buf: &[u8]) -> Result { 18 | if buf.is_empty() { 19 | Err(MessageLoadError::BadLength("C2SMessage", 1, false, 0)) 20 | } else { 21 | match buf[0] { 22 | 0 => Ok(C2SMessage::Token(buf[1..].to_vec())), 23 | 1 => { 24 | if buf.len() >= 6 { 25 | Ok(C2SMessage::Ping( 26 | u32::from_be_bytes((&buf[1..5]).try_into().unwrap()), 27 | buf[5] != 0, 28 | buf[6..].to_vec(), 29 | )) 30 | } else { 31 | Err(MessageLoadError::BadLength( 32 | "C2SMessage::Ping", 33 | 6, 34 | false, 35 | buf.len(), 36 | )) 37 | } 38 | } 39 | 2 => { 40 | if buf.len() == 17 { 41 | Ok(C2SMessage::Sub(Uuid::from_bytes( 42 | (&buf[1..]).try_into().unwrap(), 43 | ))) 44 | } else { 45 | Err(MessageLoadError::BadLength( 46 | "C2SMessage::Sub", 47 | 17, 48 | true, 49 | buf.len(), 50 | )) 51 | } 52 | } 53 | 3 => { 54 | if buf.len() == 17 { 55 | Ok(C2SMessage::Unsub(Uuid::from_bytes( 56 | (&buf[1..]).try_into().unwrap(), 57 | ))) 58 | } else { 59 | Err(MessageLoadError::BadLength( 60 | "C2SMessage::Unsub", 61 | 17, 62 | true, 63 | buf.len(), 64 | )) 65 | } 66 | } 67 | a => Err(MessageLoadError::BadEnum( 68 | "C2SMessage.type", 69 | 0..=3, 70 | a.into(), 71 | )), 72 | } 73 | } 74 | } 75 | } 76 | impl From for Vec { 77 | fn from(val: C2SMessage) -> Self { 78 | use std::iter; 79 | let a: Vec = match val { 80 | C2SMessage::Token(t) => iter::once(0).chain(t.iter().copied()).collect(), 81 | C2SMessage::Ping(p, s, d) => iter::once(1) 82 | .chain(p.to_be_bytes()) 83 | .chain(iter::once(s.into())) 84 | .chain(d.iter().copied()) 85 | .collect(), 86 | C2SMessage::Sub(s) => iter::once(2).chain(s.into_bytes()).collect(), 87 | C2SMessage::Unsub(s) => iter::once(3).chain(s.into_bytes()).collect(), 88 | }; 89 | a 90 | } 91 | } 92 | impl C2SMessage { 93 | pub fn name(&self) -> &'static str { 94 | match self { 95 | C2SMessage::Token(_) => "C2S;TOKEN", 96 | C2SMessage::Ping(_, _, _) => "C2S;PING", 97 | C2SMessage::Sub(_) => "C2S;SUB", 98 | C2SMessage::Unsub(_) => "C2S;UNSUB", 99 | } 100 | } 101 | } 102 | 103 | // impl<'a> C2SMessage<'a> { 104 | // pub fn to_array(&self) -> Box<[u8]> { 105 | // >>::into(self.clone()) 106 | // } 107 | // pub fn to_vec(&self) -> Vec { 108 | // self.to_array().to_vec() 109 | // } 110 | // } -------------------------------------------------------------------------------- /src/api/figura/websocket/types/s2c.rs: -------------------------------------------------------------------------------- 1 | use super::MessageLoadError; 2 | use std::convert::{TryFrom, TryInto}; 3 | 4 | use uuid::Uuid; 5 | 6 | #[repr(u8)] 7 | #[derive(Debug, Clone, PartialEq, Eq)] 8 | pub enum S2CMessage { 9 | Auth = 0, 10 | Ping(Uuid, u32, bool, Vec) = 1, 11 | Event(Uuid) = 2, // Updates avatar for other players 12 | Toast(u8, String, Option) = 3, 13 | Chat(String) = 4, 14 | Notice(u8) = 5, 15 | } 16 | impl TryFrom<&[u8]> for S2CMessage { 17 | 18 | type Error = MessageLoadError; 19 | 20 | fn try_from(buf: &[u8]) -> Result { 21 | if buf.is_empty() { 22 | Err(MessageLoadError::BadLength("S2CMessage", 1, false, 0)) 23 | } else { 24 | use MessageLoadError::*; 25 | use S2CMessage::*; 26 | match buf[0] { 27 | 0 => { 28 | if buf.len() == 1 { 29 | Ok(Auth) 30 | } else { 31 | Err(BadLength("S2CMessage::Auth", 1, true, buf.len())) 32 | } 33 | } 34 | 1 => { 35 | if buf.len() >= 22 { 36 | Ok(Ping( 37 | Uuid::from_bytes((&buf[1..17]).try_into().unwrap()), 38 | u32::from_be_bytes((&buf[17..21]).try_into().unwrap()), 39 | buf[21] != 0, 40 | buf[22..].to_vec(), 41 | )) 42 | } else { 43 | Err(BadLength("S2CMessage::Ping", 22, false, buf.len())) 44 | } 45 | } 46 | 2 => { 47 | if buf.len() == 17 { 48 | Ok(Event(Uuid::from_bytes((&buf[1..17]).try_into().unwrap()))) 49 | } else { 50 | Err(BadLength("S2CMessage::Event", 17, true, buf.len())) 51 | } 52 | } 53 | 3 => todo!(), 54 | 4 => todo!(), 55 | 5 => todo!(), 56 | a => Err(BadEnum("S2CMessage.type", 0..=5, a.into())), 57 | } 58 | } 59 | } 60 | } 61 | 62 | impl From for Vec { 63 | fn from(val: S2CMessage) -> Self { 64 | use std::iter::once; 65 | use S2CMessage::*; 66 | match val { 67 | Auth => vec![0], 68 | Ping(u, i, s, d) => once(1) 69 | .chain(u.into_bytes().iter().copied()) 70 | .chain(i.to_be_bytes().iter().copied()) 71 | .chain(once(if s { 1 } else { 0 })) 72 | .chain(d.iter().copied()) 73 | .collect(), 74 | Event(u) => once(2).chain(u.into_bytes().iter().copied()).collect(), 75 | Toast(t, h, d) => once(3) 76 | .chain(once(t)) 77 | .chain(h.as_bytes().iter().copied()) 78 | .chain( 79 | d.into_iter() 80 | .flat_map(|s| once(0).chain(s.as_bytes().iter().copied()).collect::>()), // FIXME: Try find other solution 81 | ) 82 | .collect(), 83 | Chat(c) => once(4).chain(c.as_bytes().iter().copied()).collect(), 84 | Notice(t) => vec![5, t], 85 | } 86 | } 87 | } 88 | impl S2CMessage { 89 | pub fn name(&self) -> &'static str { 90 | match self { 91 | S2CMessage::Auth => "S2C;AUTH", 92 | S2CMessage::Ping(_, _, _, _) => "S2C;PING", 93 | S2CMessage::Event(_) => "S2C;EVENT", 94 | S2CMessage::Toast(_, _, _) => "S2C;TOAST", 95 | S2CMessage::Chat(_) => "S2C;CHAT", 96 | S2CMessage::Notice(_) => "S2C;NOTICE", 97 | } 98 | } 99 | } 100 | 101 | // impl<'a> S2CMessage<'a> { 102 | // pub fn to_array(&self) -> Box<[u8]> { 103 | // >>::into(self.clone()) 104 | // } 105 | // pub fn to_vec(&self) -> Vec { 106 | // self.to_array().to_vec() 107 | // } 108 | // } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | - English 2 | - [Русский](README.ru.md) 3 | 4 | # The Sculptor 5 | 6 | [![CI](https://github.com/shiroyashik/sculptor/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/shiroyashik/sculptor/actions/workflows/ci.yml) 7 | 8 | Unofficial backend for the Minecraft mod [Figura](https://github.com/FiguraMC/Figura). 9 | 10 | Is a worthy replacement for the official version. Realized all the functionality that can be used during the game. 11 | 12 | And also a distinctive feature is the possibility of player identification through third-party authentication providers (such as [Ely.By](https://ely.by/)) 13 | 14 | ## Public server 15 | 16 | [![Server status](https://up.shsr.ru/api/badge/1/status?upLabel=Online&downLabel=Offline&label=Server+status)](https://up.shsr.ru/status/pub) 17 | 18 | I'm keeping the public server running at the moment! 19 | 20 | You can use it if running your own Sculptor instance is difficult for you. 21 | 22 | To connect, simply change **Figura Cloud IP** in Figura settings to the address below: 23 | 24 | > figura.shsr.ru 25 | 26 | Authentication is enabled on the server via: Mojang(Microsoft) and [Ely.By](https://ely.by/) 27 | 28 | ## Launch 29 | 30 | To run it you will need a configured reverse proxy server. 31 | 32 | Make sure that the reverse proxy you are using supports WebSocket and valid certificates are used for HTTPS connections. 33 | 34 | > [!WARNING] 35 | > NGINX requires additional configuration to work with websocket! 36 | 37 | ### Docker 38 | 39 | For the template you can use [docker-compose.example.yml](docker-compose.example.yml) 40 | 41 | It assumes you will be using Traefik as a reverse proxy, if so uncomment the lines and add Sculptor to the network with Traefik. 42 | 43 | Copy [Config.example.toml](Config.example.toml) change the settings as desired and rename to Config.toml 44 | 45 | That's enough to start Sculptor. 46 | 47 | ### Pre-Built 48 | 49 | See the [pre-built archives](https://github.com/shiroyashik/sculptor/releases/latest) 50 | 51 | ### Build from source 52 | 53 | A pre-installed Rust will be required for the build 54 | 55 | ```sh 56 | # Clone the pre-release 57 | git clone https://github.com/shiroyashik/sculptor.git 58 | # or clone specific version 59 | git clone --depth 1 --branch v0.4.0 https://github.com/shiroyashik/sculptor.git 60 | # Enter the folder 61 | cd sculptor 62 | # Copy Sculptor configuration file 63 | cp Config.example.toml Config.toml 64 | # Edit configuration file for your needs 65 | nano Config.toml 66 | # Build it in release mode for better performance 67 | cargo build --release 68 | # or run from cargo 69 | cargo run --release 70 | ``` 71 | 72 | #### Compiling from the `master` Branch 73 | 74 | > [!IMPORTANT] 75 | > Installing Sculptor directly from the `master` branch is **not recommended** for most users. This branch contains pre-release code that is actively being developed and may include broken or unstable features. Additionally, using the `master` branch could potentially cause issues with data migration when upgrading to future stable releases. 76 | > 77 | > If you still choose to use the `master` branch, please be aware that you may encounter bugs or unexpected behavior. Your feedback and bug reports are highly appreciated. However, for a more stable and reliable experience, we strongly advise using the **latest official release** instead. 78 | 79 | ## Contributing 80 | ![Ask me anything!](https://img.shields.io/badge/Ask%20me-anything-1abc9c.svg) 81 | on 82 | [![Telegram](https://badgen.net/static/icon/telegram?icon=telegram&color=cyan&label)](https://t.me/shiroyashik) 83 | or 84 | ![Discord](https://badgen.net/badge/icon/discord?icon=discord&label) 85 | 86 | If you have ideas for new features, have found a bug, or want to suggest improvements, 87 | please create an [issue](https://github.com/shiroyashik/sculptor/issues) 88 | or contact me directly via Discord/Telegram (**@shiroyashik**). 89 | 90 | If you are a Rust developer, you can modify the code yourself and request a Pull Request: 91 | 92 | 1. Fork the repository. 93 | 2. Create a new branch for your features or fixes. 94 | 3. Submit a PR. 95 | 96 | Glad for any help from ideas to PRs. ❤ 97 | 98 | ## License 99 | 100 | The Sculptor is licensed under [GPL-3.0](LICENSE) 101 | -------------------------------------------------------------------------------- /README.ru.md: -------------------------------------------------------------------------------- 1 | * [English](README.md) 2 | * Русский 3 | 4 | # The Sculptor 5 | [![CI](https://github.com/shiroyashik/sculptor/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/shiroyashik/sculptor/actions/workflows/ci.yml) 6 | 7 | Неофициальный бэкенд для Minecraft мода [Figura](https://github.com/FiguraMC/Figura). 8 | 9 | Это полноценная замена официальной версии. Реализован весь функционал который вы можете использовать во время игры. 10 | 11 | А также отличительной особенностью является возможность игры с сторонними провайдерерами аутентификации (такими как [Ely.By](https://ely.by/)) 12 | 13 | ## Публичный сервер 14 | 15 | [![Статус сервера](https://up.shsr.ru/api/badge/1/status?upLabel=Online&downLabel=Offline&label=Server+status)](https://up.shsr.ru/status/pub) 16 | 17 | Я держу запущенным публичный сервер! 18 | 19 | Вы можете использовать его если запуск собственного сервера затруднителен для вас. 20 | 21 | Для подключения достаточно сменить **IP сервера Figura** в настройках Figura на адрес ниже: 22 | 23 | > figura.shsr.ru 24 | 25 | На сервере включена аутентификация через: Mojang(Microsoft) и [Ely.By](https://ely.by/) 26 | 27 | ## Запуск 28 | 29 | Для его запуска вам понадобится настроенный обратный прокси-сервер. 30 | 31 | Убедитесь, что используемый вами обратный прокси-сервер поддерживает WebSocket, а для HTTPS-соединений используются действительные сертификаты. 32 | 33 | > [!WARNING] 34 | > NGINX требует дополнительной настройки для работы с websocket! 35 | 36 | ### Docker 37 | 38 | Как шаблон для начала можете использовать [docker-compose.example.yml](docker-compose.example.yml) 39 | 40 | Предполагается, что вы будете использовать Traefik в качестве обратного прокси, если это так, раскомментируйте строки и добавьте Sculptor в сеть с Traefik. 41 | 42 | Скопируйте [Config.example.toml](Config.example.toml) переименуйте в Config.toml и настройте по своему желанию. 43 | 44 | Запустите! `docker compose up -d` 45 | 46 | ### Исполняемые файлы 47 | 48 | Смотрите [прикреплённые архивы к релизам](https://github.com/shiroyashik/sculptor/releases/latest) 49 | 50 | ### Собираем из исходников 51 | 52 | Для сборки потребуется предустановленный Rust 53 | 54 | ```sh 55 | # Клонируем пре-релиз 56 | git clone https://github.com/shiroyashik/sculptor.git 57 | # или из выбранного тега 58 | git clone --depth 1 --branch v0.4.0 https://github.com/shiroyashik/sculptor.git 59 | # Переходим в репу 60 | cd sculptor 61 | # Меняем имя конфиг файлу 62 | cp Config.example.toml Config.toml 63 | # Изменяем настройки (по желанию) 64 | nano Config.toml 65 | # Собираем с Release профилем для большей производительности 66 | cargo build --release 67 | # или запускаем прям из под cargo 68 | cargo run --release 69 | ``` 70 | 71 | #### Сборка из `master` ветки 72 | 73 | > [!IMPORTANT] 74 | > Сборка Sculptor непосредственно из ветки `master` **не рекомендуется** для большинства пользователей. Эта ветка содержит предрелизный код, который активно разрабатывается и может содержать неработающие или нестабильные функции. Кроме того, использование ветки `master` может привести к проблемам с миграцией данных при обновлении до будущих стабильных релизов. 75 | > 76 | > Если вы все же решили использовать ветку `master`, пожалуйста, имейте в виду, что вы можете столкнуться с ошибками или некорректным поведением. Тем не менее ваши сообщения об ошибках высоко ценятся. Однако для более стабильной и надежной работы настоятельно рекомендую использовать **последний официальный релиз**. 77 | 78 | ## Вклад в развитие 79 | ![Спроси меня о чём угодно!](https://img.shields.io/badge/Ask%20me-anything-1abc9c.svg) 80 | в 81 | [![Telegram](https://badgen.net/static/icon/telegram?icon=telegram&color=cyan&label)](https://t.me/shiroyashik) 82 | или 83 | ![Discord](https://badgen.net/badge/icon/discord?icon=discord&label) 84 | 85 | Если у вас есть идем, нашли баг или хотите предложить улучшения 86 | создавайте [issue](https://github.com/shiroyashik/sculptor/issues) 87 | или свяжитесь со мной напрямую через Discord/Telegram (**@shiroyashik**). 88 | 89 | Если вы Rust разработчик, буду рад вашим Pull Request'ам: 90 | 91 | 1. Форкните репу 92 | 2. Создайте новую ветку 93 | 3. Создайте PR! 94 | 95 | Буду рад любой вашей помощи! ❤ 96 | 97 | ## License 98 | 99 | The Sculptor is licensed under [GPL-3.0](LICENSE) 100 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | run-name: Release ${{ github.ref_name }} 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v*.*.*' 8 | 9 | permissions: 10 | contents: write 11 | packages: write 12 | 13 | env: 14 | RUST_VERSION: 1.88 15 | ZIG_VERSION: 0.14.1 16 | ALPINE_VERSION: 3.22 17 | CARGO_TERM_COLOR: always 18 | CARGO_BUILD_TARGETS: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,x86_64-pc-windows-gnu 19 | 20 | jobs: 21 | build-binary: 22 | name: Build binaries and upload them as artifacts 23 | runs-on: ubuntu-latest 24 | env: 25 | OUTPUT_DIR: target/output 26 | outputs: 27 | binary-artifact-id: ${{ steps.artifact-upload.outputs.artifact-id }} 28 | steps: 29 | 30 | - name: Checkout the code 31 | uses: actions/checkout@v4 32 | 33 | - name: Use build cache 34 | uses: Swatinem/rust-cache@v2 35 | with: 36 | prefix-key: "cargo-v0" 37 | cache-all-crates: true 38 | 39 | - name: Set up Rust toolchain 40 | uses: dtolnay/rust-toolchain@master 41 | with: 42 | toolchain: ${{ env.RUST_VERSION }} 43 | targets: ${{ env.CARGO_BUILD_TARGETS }} 44 | # needed if we want to use linting in build action 45 | # components: clippy, rustfmt 46 | 47 | - name: Install the build dependencies 48 | uses: ./.github/actions/dependencies 49 | with: 50 | zig-version: ${{ env.ZIG_VERSION }} 51 | 52 | 53 | - name: Build the project 54 | uses: ./.github/actions/build 55 | with: 56 | targets: ${{ env.CARGO_BUILD_TARGETS }} 57 | lint: false 58 | 59 | - name: Create output directory for artifacts 60 | run: mkdir -p "$OUTPUT_DIR" 61 | 62 | - name: Package the artifacts 63 | run: ./.github/scripts/package-artifacts.sh 64 | 65 | - name: Upload artifact 66 | id: artifact-upload 67 | uses: actions/upload-artifact@v4 68 | with: 69 | path: ${{ env.OUTPUT_DIR }}/* 70 | name: binaries-${{ github.ref_name }} 71 | 72 | build-image: 73 | name: Build image and push to GHCR 74 | runs-on: ubuntu-latest 75 | steps: 76 | 77 | - name: Checkout the code 78 | uses: actions/checkout@v4 79 | 80 | # if we wanted to push to DockerHub: 81 | # - name: Login to DockerHub 82 | # uses: docker/login-action@v3 83 | # with: 84 | # username: ${{ secrets.DOCKERHUB_USERNAME }} 85 | # password: ${{ secrets.DOCKERHUB_TOKEN }} 86 | # also uncomment the tags parameter in the last step 87 | 88 | - name: Login to GitHub Container Registry 89 | uses: docker/login-action@v3 90 | with: 91 | registry: ghcr.io 92 | username: ${{ github.repository_owner }} 93 | password: ${{ secrets.GITHUB_TOKEN }} 94 | 95 | - name: Set up QEMU 96 | uses: docker/setup-qemu-action@v3 97 | 98 | - name: Set up buildx 99 | uses: docker/setup-buildx-action@v3 100 | 101 | - name: Build and push 102 | uses: docker/build-push-action@v6 103 | with: 104 | context: . 105 | file: ./Dockerfile 106 | build-args: | 107 | ALPINE_VERSION 108 | RUST_VERSION 109 | platforms: | 110 | linux/amd64 111 | linux/arm64 112 | push: true 113 | tags: | 114 | ghcr.io/${{ github.repository_owner }}/sculptor:latest 115 | ghcr.io/${{ github.repository_owner }}/sculptor:${{ github.ref_name }} 116 | # ${{ github.repository_owner }}/sculptor:latest 117 | # ${{ github.repository_owner }}/sculptor:${{ github.ref_name }} 118 | provenance: false 119 | sbom: false 120 | cache-from: type=gha 121 | cache-to: type=gha,mode=max 122 | 123 | create-release: 124 | name: Create GitHub release 125 | needs: 126 | - build-binary 127 | - build-image 128 | runs-on: ubuntu-latest 129 | steps: 130 | 131 | - name: Checkout the code 132 | uses: actions/checkout@v4 133 | with: 134 | fetch-tags: true 135 | ref: ${{ github.ref }} 136 | 137 | - name: Download the artifacts 138 | uses: actions/download-artifact@v4 139 | with: 140 | artifact-ids: ${{ needs.build-binary.outputs.binary-artifact-id }} 141 | 142 | - name: Create release 143 | env: 144 | GH_TOKEN: ${{ github.token }} 145 | run: | 146 | gh release create ${{ github.ref_name }} \ 147 | --verify-tag \ 148 | --generate-notes \ 149 | --latest \ 150 | --draft \ 151 | binaries-${{ github.ref_name }}/* -------------------------------------------------------------------------------- /src/api/figura/profile.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | body::Bytes, extract::{Path, State}, Json 3 | }; 4 | use tracing::debug; 5 | use serde_json::{json, Value}; 6 | use tokio::{ 7 | fs, 8 | io::{self, AsyncReadExt, BufWriter}, 9 | }; 10 | use uuid::Uuid; 11 | 12 | use crate::{ 13 | api::errors::internal_and_log, 14 | auth::Token, utils::{calculate_file_sha256, format_uuid}, 15 | ApiError, ApiResult, AppState, AVATARS_VAR 16 | }; 17 | use super::websocket::S2CMessage; 18 | 19 | pub async fn user_info( 20 | Path(uuid): Path, 21 | State(state): State, 22 | ) -> ApiResult> { 23 | tracing::info!("Receiving profile information for {}", uuid); 24 | 25 | let formatted_uuid = format_uuid(&uuid); 26 | 27 | let avatar_file = format!("{}/{}.moon", *AVATARS_VAR, formatted_uuid); 28 | 29 | let userinfo = if let Some(info) = state.user_manager.get_by_uuid(&uuid) { info } else { 30 | return Err(ApiError::BadRequest) // NOTE: Not Found (404) shows badge 31 | }; 32 | 33 | let mut user_info_response = json!({ 34 | "uuid": &formatted_uuid, 35 | "rank": userinfo.rank, 36 | "equipped": [], 37 | "lastUsed": userinfo.last_used, 38 | "equippedBadges": { 39 | "special": [0,0,0,0,0,0], 40 | "pride": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] 41 | }, 42 | "version": userinfo.version, 43 | "banned": userinfo.banned 44 | }); 45 | 46 | if let Some(settings) = state.config.read().await.advanced_users.clone().get(&uuid) { 47 | let badges = user_info_response 48 | .get_mut("equippedBadges") 49 | .and_then(Value::as_object_mut) 50 | .unwrap(); 51 | badges.append( 52 | json!({ 53 | "special": settings.special, 54 | "pride": settings.pride 55 | }) 56 | .as_object_mut() 57 | .unwrap(), 58 | ) 59 | } 60 | 61 | if fs::metadata(&avatar_file).await.is_ok() { 62 | if let Some(equipped) = user_info_response 63 | .get_mut("equipped") 64 | .and_then(Value::as_array_mut) 65 | { 66 | match calculate_file_sha256(&avatar_file) { 67 | Ok(hash) => equipped.push(json!({ 68 | "id": "avatar", 69 | "owner": &formatted_uuid, 70 | "hash": hash 71 | })), 72 | Err(_e) => {} 73 | } 74 | } 75 | } 76 | Ok(Json(user_info_response)) 77 | } 78 | 79 | pub async fn download_avatar(Path(uuid): Path) -> ApiResult> { 80 | let uuid = format_uuid(&uuid); 81 | tracing::info!("Requesting an avatar: {}", uuid); 82 | let mut file = if let Ok(file) = fs::File::open(format!("{}/{}.moon", *AVATARS_VAR, uuid)).await { 83 | file 84 | } else { 85 | return Err(ApiError::NotFound) 86 | }; 87 | let mut buffer = Vec::new(); 88 | file.read_to_end(&mut buffer).await.map_err(internal_and_log)?; 89 | Ok(buffer) 90 | } 91 | 92 | pub async fn upload_avatar( 93 | Token(token): Token, 94 | State(state): State, 95 | body: Bytes, 96 | ) -> ApiResult { 97 | let request_data = body; 98 | 99 | if let Some(user_info) = state.user_manager.get(&token) { 100 | tracing::info!( 101 | "{} ({}) trying to upload an avatar", 102 | user_info.uuid, 103 | user_info.nickname 104 | ); 105 | let avatar_file = format!("{}/{}.moon", *AVATARS_VAR, user_info.uuid); 106 | let mut file = BufWriter::new(fs::File::create(&avatar_file).await.map_err(internal_and_log)?); 107 | io::copy(&mut request_data.as_ref(), &mut file).await.map_err(internal_and_log)?; 108 | } 109 | Ok("ok".to_string()) 110 | } 111 | 112 | pub async fn equip_avatar(Token(token): Token, State(state): State) -> ApiResult<&'static str> { 113 | debug!("[API] S2C : Equip"); 114 | let uuid = state.user_manager.get(&token).ok_or(ApiError::Unauthorized)?.uuid; 115 | send_event(&state, &uuid).await; 116 | Ok("ok") 117 | } 118 | 119 | pub async fn delete_avatar(Token(token): Token, State(state): State) -> ApiResult { 120 | if let Some(user_info) = state.user_manager.get(&token) { 121 | tracing::info!( 122 | "{} ({}) is trying to delete the avatar", 123 | user_info.uuid, 124 | user_info.nickname 125 | ); 126 | let avatar_file = format!("{}/{}.moon", *AVATARS_VAR, user_info.uuid); 127 | fs::remove_file(avatar_file).await.map_err(internal_and_log)?; 128 | send_event(&state, &user_info.uuid).await; 129 | } 130 | Ok("ok".to_string()) 131 | } 132 | 133 | pub async fn send_event(state: &AppState, uuid: &Uuid) { 134 | // To user subscribers 135 | if let Some(broadcast) = state.subscribes.get(uuid) { 136 | if broadcast.send(S2CMessage::Event(*uuid).into()).is_err() { 137 | debug!("[WebSocket] Failed to send Event! There is no one to send. UUID: {uuid}") 138 | }; 139 | } else { 140 | debug!("[WebSocket] Failed to send Event! Can't find UUID: {uuid}") 141 | }; 142 | // To user 143 | if let Some(session) = state.session.get(uuid) { 144 | if session.send(super::SessionMessage::Ping(S2CMessage::Event(*uuid).into())).await.is_err() { 145 | debug!("[WebSocket] Failed to send Event! WS doesn't connected? UUID: {uuid}") 146 | }; 147 | } else { 148 | debug!("[WebSocket] Failed to send Event! Can't find UUID: {uuid}") 149 | }; 150 | } -------------------------------------------------------------------------------- /src/utils/auxiliary.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::Read, path::{Path, PathBuf}, sync::Arc}; 2 | 3 | use notify::{Event, Watcher}; 4 | use tokio::{io::AsyncReadExt, sync::RwLock}; 5 | use base64::prelude::*; 6 | use rand::{rng, Rng}; 7 | use ring::digest::{self, digest}; 8 | use uuid::Uuid; 9 | use chrono::prelude::*; 10 | 11 | use crate::{auth::Userinfo, state::{BannedPlayer, Config}, UManager}; 12 | 13 | pub fn rand() -> [u8; 50] { 14 | let mut rng = rng(); 15 | let distr = rand::distr::Uniform::new_inclusive(0, 255).expect("rand() failure."); 16 | let mut nums: [u8; 50] = [0u8; 50]; 17 | for x in &mut nums { 18 | *x = rng.sample(distr); 19 | } 20 | nums 21 | } 22 | 23 | pub async fn update_advanced_users( 24 | path: PathBuf, 25 | umanager: Arc, 26 | sessions: Arc>>, 27 | config: Arc>, 28 | ) { 29 | let (tx, mut rx) = tokio::sync::mpsc::channel::>(1); 30 | tx.send(Ok(notify::Event::default())).await.unwrap(); 31 | let mut watcher = notify::PollWatcher::new( 32 | move |res| { 33 | tx.blocking_send(res).unwrap(); 34 | }, 35 | notify::Config::default(), 36 | ).unwrap(); 37 | watcher.watch(&path, notify::RecursiveMode::NonRecursive).unwrap(); 38 | 39 | let mut first_time = true; 40 | while rx.recv().await.is_some() { 41 | let new_config = Config::parse(path.clone()); 42 | let mut config = config.write().await; 43 | 44 | if new_config != *config || first_time { 45 | if !first_time { tracing::info!("Server configuration modification detected!") } 46 | first_time = false; 47 | *config = new_config; 48 | let users: Vec<(Uuid, Userinfo)> = config.advanced_users 49 | .iter() 50 | .map( |(uuid, userdata)| { 51 | ( 52 | *uuid, 53 | Userinfo { 54 | uuid: *uuid, 55 | nickname: userdata.username.clone(), 56 | banned: userdata.banned, 57 | ..Default::default() 58 | } 59 | )}) 60 | .collect(); 61 | 62 | for (uuid, userinfo) in users { 63 | umanager.insert_user(uuid, userinfo.clone()); 64 | if userinfo.banned { 65 | umanager.ban(&userinfo); 66 | if let Some(tx) = sessions.get(&uuid) {let _ = tx.send(crate::api::figura::SessionMessage::Banned).await;} 67 | } else { 68 | umanager.unban(&uuid); 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | pub async fn update_bans_from_minecraft( 76 | folder: PathBuf, 77 | umanager: Arc, 78 | sessions: Arc>> 79 | ) { 80 | let path = folder.join("banned-players.json"); 81 | let mut file = tokio::fs::File::open(path.clone()).await.expect("Access denied or banned-players.json doesn't exists!"); 82 | let mut data = String::new(); 83 | // vars end 84 | 85 | // initialize 86 | file.read_to_string(&mut data).await.expect("cant read banned-players.json"); 87 | let mut old_bans: Vec = serde_json::from_str(&data).expect("cant parse banned-players.json"); 88 | 89 | if !old_bans.is_empty() { 90 | let names: Vec = old_bans.iter().map(|user| user.name.clone()).collect(); 91 | tracing::info!("Banned players: {}", names.join(", ")); 92 | } 93 | 94 | for player in &old_bans { 95 | umanager.ban(&player.clone().into()); 96 | if let Some(tx) = sessions.get(&player.uuid) {let _ = tx.send(crate::api::figura::SessionMessage::Banned).await;} 97 | } 98 | 99 | let (tx, mut rx) = tokio::sync::mpsc::channel::>(1); 100 | let mut watcher = notify::PollWatcher::new( 101 | move |res| { 102 | tx.blocking_send(res).unwrap(); 103 | }, 104 | notify::Config::default(), 105 | ).unwrap(); 106 | watcher.watch(&path, notify::RecursiveMode::NonRecursive).unwrap(); 107 | 108 | // old_bans 109 | while rx.recv().await.is_some() { 110 | let mut file = tokio::fs::File::open(path.clone()).await.expect("Access denied or file doesn't exists!"); 111 | let mut data = String::new(); 112 | file.read_to_string(&mut data).await.expect("cant read banned-players.json"); 113 | let new_bans: Vec = if let Ok(res) = serde_json::from_str(&data) { res } else { 114 | tracing::error!("Error occured while parsing a banned-players.json"); 115 | continue; 116 | }; 117 | 118 | if new_bans != old_bans { 119 | tracing::info!("Minecraft ban list modification detected!"); 120 | let unban: Vec<&BannedPlayer> = old_bans.iter().filter(|user| !new_bans.contains(user)).collect(); 121 | let mut unban_names = unban.iter().map(|user| user.name.clone()).collect::>().join(", "); 122 | if !unban.is_empty() { 123 | for player in unban { 124 | umanager.unban(&player.uuid); 125 | } 126 | } else { unban_names = String::from("-")}; 127 | let ban: Vec<&BannedPlayer> = new_bans.iter().filter(|user| !old_bans.contains(user)).collect(); 128 | let mut ban_names = ban.iter().map(|user| user.name.clone()).collect::>().join(", "); 129 | if !ban.is_empty() { 130 | for player in ban { 131 | umanager.ban(&player.clone().into()); 132 | if let Some(tx) = sessions.get(&player.uuid) {let _ = tx.send(crate::api::figura::SessionMessage::Banned).await;} 133 | } 134 | } else { ban_names = String::from("-")}; 135 | tracing::info!("List of changes:\n Banned: {ban_names}\n Unbanned: {unban_names}"); 136 | // Write new to old for next iteration 137 | old_bans = new_bans; 138 | } 139 | } 140 | } 141 | 142 | pub fn format_uuid(uuid: &Uuid) -> String { 143 | // let uuid = Uuid::parse_str(&uuid)?; TODO: Вероятно format_uuid стоит убрать 144 | // .map_err(|_| tide::Error::from_str(StatusCode::InternalServerError, "Failed to parse UUID"))?; 145 | uuid.as_hyphenated().to_string() 146 | } 147 | 148 | pub fn calculate_file_sha256(file_path: &str) -> Result { 149 | // Read the file content 150 | let mut file = File::open(file_path)?; 151 | let mut content = Vec::new(); 152 | file.read_to_end(&mut content)?; 153 | 154 | // Convert the content to base64 155 | let base64_content = BASE64_STANDARD.encode(&content); 156 | 157 | // Calculate the SHA-256 hash of the base64 string 158 | let binding = digest(&digest::SHA256, base64_content.as_bytes()); 159 | let hash = binding.as_ref(); 160 | 161 | // Convert the hash to a hexadecimal string 162 | let hex_hash = faster_hex::hex_string(hash); 163 | 164 | Ok(hex_hash) 165 | } 166 | 167 | pub fn get_log_file(folder: &str) -> String { 168 | let local_date = Local::now().format("%Y-%m-%d"); 169 | let mut index: u16 = 0; 170 | loop { 171 | let file_name = format!("{local_date}.{:04}.log", index); 172 | let file_path = Path::new(folder).join(&file_name); 173 | if !Path::new(&file_path).exists() { 174 | return file_name; 175 | } 176 | index += 1; 177 | } 178 | } 179 | 180 | pub fn get_limit_as_bytes(limit: usize) -> usize { 181 | 1024 + limit * 1024 // Adding additional 1 KB just for fun :) 182 | } -------------------------------------------------------------------------------- /src/utils/check_updates.rs: -------------------------------------------------------------------------------- 1 | use std::path::{self, PathBuf}; 2 | 3 | use anyhow::bail; 4 | use reqwest::Client; 5 | use semver::Version; 6 | use serde::{Deserialize, Serialize}; 7 | use tokio::{fs::{self, File}, io::{AsyncReadExt as _, AsyncWriteExt as _}}; 8 | 9 | use crate::{ASSETS_VAR, FIGURA_ASSETS_ZIP_URL, FIGURA_RELEASES_URL, TIMEOUT, USER_AGENT}; 10 | 11 | #[derive(Deserialize, Debug)] 12 | struct Tag { 13 | name: String 14 | } 15 | 16 | pub async fn get_latest_version(repo: &str) -> anyhow::Result { 17 | let url = format!("https://api.github.com/repos/{repo}/tags"); 18 | let client = Client::builder().timeout(TIMEOUT).user_agent(USER_AGENT).build().unwrap(); 19 | let response = client.get(&url).send().await?; 20 | 21 | if response.status().is_success() { 22 | let tags: Vec = response.json().await?; 23 | let latest_tag = tags.iter() 24 | .filter_map(|tag| { 25 | if tag.name.starts_with('v') { // v#.#.# 26 | Version::parse(&tag.name[1..]).ok() 27 | } else { 28 | None 29 | } 30 | }) 31 | .max(); 32 | if let Some(latest_version) = latest_tag { 33 | Ok(latest_version) 34 | } else { 35 | bail!("Can't find version tags!") 36 | } 37 | } else { 38 | bail!("Response status code: {}", response.status().as_u16()) 39 | } 40 | } 41 | 42 | // Figura 43 | 44 | #[derive(Deserialize, Debug)] 45 | struct Release { 46 | tag_name: String, 47 | prerelease: bool 48 | } 49 | 50 | pub async fn get_figura_versions() -> anyhow::Result { 51 | let client = Client::builder().timeout(TIMEOUT).user_agent(USER_AGENT).build().unwrap(); 52 | let response = client.get(FIGURA_RELEASES_URL).send().await?; 53 | 54 | let mut release_ver = Version::new(0, 0, 0); 55 | let mut prerelease_ver = Version::new(0, 0, 0); 56 | 57 | if response.status().is_success() { 58 | let multiple_releases: Vec = response.json().await?; 59 | for release in multiple_releases { 60 | let tag_ver = if let Ok(res) = Version::parse(&release.tag_name) { res } else { 61 | tracing::error!("Incorrect tag name! {release:?}"); 62 | continue; 63 | }; 64 | if release.prerelease { 65 | if tag_ver > prerelease_ver { 66 | prerelease_ver = tag_ver 67 | } 68 | } else if tag_ver > release_ver { 69 | release_ver = tag_ver 70 | } 71 | } 72 | if release_ver > prerelease_ver { 73 | prerelease_ver = release_ver.clone(); 74 | } 75 | // Stop 76 | Ok(FiguraVersions { release: release_ver.to_string(), prerelease: prerelease_ver.to_string() }) 77 | } else { 78 | bail!("Response status code: {}", response.status().as_u16()) 79 | } 80 | } 81 | 82 | #[derive(Serialize, Debug, Clone)] 83 | pub struct FiguraVersions { 84 | pub release: String, 85 | pub prerelease: String 86 | } 87 | 88 | // Assets 89 | 90 | #[derive(Deserialize, Debug)] 91 | struct Commit { 92 | sha: String 93 | } 94 | 95 | pub fn get_path_to_assets_hash() -> PathBuf { 96 | path::PathBuf::from(&*ASSETS_VAR).join("..").join("assets_last_commit") 97 | } 98 | 99 | pub async fn get_commit_sha(url: &str) -> anyhow::Result { 100 | let client = Client::builder().timeout(TIMEOUT).user_agent(USER_AGENT).build().unwrap(); 101 | let response: reqwest::Response = client.get(url).send().await?; 102 | let commit: Commit = response.json().await?; 103 | Ok(commit.sha) 104 | } 105 | 106 | pub async fn is_assets_outdated(last_sha: &str) -> anyhow::Result { 107 | let path = get_path_to_assets_hash(); 108 | 109 | match File::open(path.clone()).await { 110 | Ok(mut file) => { 111 | let mut contents = String::new(); 112 | file.read_to_string(&mut contents).await?; 113 | if contents.lines().count() != 1 { 114 | // Lines count in file abnormal 115 | Ok(true) 116 | } else if contents == last_sha { 117 | Ok(false) 118 | } else { 119 | // SHA in file mismatches with provided SHA 120 | Ok(true) 121 | } 122 | }, 123 | Err(err) => if err.kind() == tokio::io::ErrorKind::NotFound { 124 | // Can't find file 125 | Ok(true) 126 | } else { 127 | anyhow::bail!("{:?}", err); 128 | } 129 | } 130 | } 131 | 132 | pub fn download_assets() -> anyhow::Result<()> { 133 | use std::{fs::{File, self}, io::Write as _}; 134 | 135 | let assets_folder = ASSETS_VAR.clone(); 136 | 137 | // Path to save the downloaded ZIP file 138 | let mut zip_file_path = path::PathBuf::from(&*ASSETS_VAR); 139 | zip_file_path.pop(); 140 | let zip_file_path = zip_file_path.join("assets.zip"); 141 | 142 | // Download the ZIP file 143 | let client = reqwest::blocking::Client::builder().timeout(TIMEOUT).user_agent(USER_AGENT).build().unwrap(); 144 | let response: reqwest::blocking::Response = client.get(FIGURA_ASSETS_ZIP_URL).send()?; 145 | let bytes = response.bytes()?; 146 | 147 | // Save the downloaded ZIP file to disk 148 | let mut file = File::create(&zip_file_path)?; 149 | file.write_all(&bytes)?; 150 | file.flush()?; 151 | 152 | // Open the downloaded ZIP file 153 | let file = File::open(&zip_file_path)?; 154 | 155 | let mut archive = zip::ZipArchive::new(file)?; 156 | let mut extraction_info = String::from("Extraction complete! More info:\n"); 157 | let mut first_folder = String::new(); 158 | 159 | for i in 0..archive.len() { 160 | let mut file = archive.by_index(i)?; 161 | let zipoutpath = match file.enclosed_name() { 162 | Some(path) => path, 163 | None => continue, 164 | }; 165 | 166 | // Folder name spoofing 167 | if i == 0 { 168 | if file.is_dir() { 169 | first_folder = zipoutpath.to_str().ok_or_else(|| anyhow::anyhow!("0 index doesn't contains path!"))?.to_string(); 170 | } else { 171 | anyhow::bail!("0 index is not a folder!") 172 | } 173 | } 174 | let mut outpath = path::PathBuf::from(&assets_folder); 175 | outpath.push(zipoutpath.strip_prefix(first_folder.clone())?); 176 | // Spoof end 177 | 178 | { 179 | let comment = file.comment(); 180 | if !comment.is_empty() { 181 | extraction_info.push_str(&format!("File {i} comment: {comment}\n")); 182 | } 183 | } 184 | if file.is_dir() { 185 | extraction_info.push_str(&format!("Dir {} extracted to \"{}\"\n", i, outpath.display())); 186 | fs::create_dir_all(&outpath)?; 187 | } else { 188 | extraction_info.push_str(&format!( 189 | "File {} extracted to \"{}\" ({} bytes)\n", 190 | i, 191 | outpath.display(), 192 | file.size() 193 | )); 194 | if let Some(p) = outpath.parent() { 195 | if !p.exists() { 196 | fs::create_dir_all(p)?; 197 | } 198 | } 199 | let mut outfile = fs::File::create(&outpath)?; 200 | std::io::copy(&mut file, &mut outfile)?; 201 | } 202 | } 203 | extraction_info.pop(); // Removes \n from end 204 | tracing::debug!("{extraction_info}"); 205 | Ok(()) 206 | } 207 | 208 | pub async fn write_sha_to_file(sha: &str) -> anyhow::Result<()> { 209 | let path = get_path_to_assets_hash(); 210 | 211 | let mut file = File::create(path).await?; 212 | file.write_all(sha.as_bytes()).await?; 213 | file.flush().await?; 214 | Ok(()) 215 | } 216 | 217 | pub async fn remove_assets() { 218 | fs::remove_dir_all(&*ASSETS_VAR).await.unwrap_or_else(|err| tracing::debug!("Assets dir remove failed due {err:?}")); 219 | fs::remove_file(get_path_to_assets_hash()).await.unwrap_or_else(|err| tracing::debug!("Assets hash file remove failed due {err:?}")); 220 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::module_inception)] 2 | use anyhow::Result; 3 | use axum::{ 4 | extract::DefaultBodyLimit, routing::{delete, get, post, put}, Router 5 | }; 6 | use dashmap::DashMap; 7 | use tracing_panic::panic_hook; 8 | use tracing_subscriber::{fmt::{self, time::ChronoLocal}, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; 9 | use std::{env::var, path::PathBuf, sync::{Arc, LazyLock}}; 10 | use tokio::{fs, sync::RwLock, time::Instant}; 11 | use tower_http::trace::TraceLayer; 12 | 13 | // Consts 14 | mod consts; 15 | pub use consts::*; 16 | 17 | // Errors 18 | pub use api::errors::{ApiResult, ApiError}; 19 | 20 | // Metrics 21 | mod metrics; 22 | pub use metrics::*; 23 | 24 | // API 25 | mod api; 26 | use api::figura::{ws, info as api_info, profile as api_profile, auth as api_auth, assets as api_assets}; 27 | 28 | // Auth 29 | mod auth; 30 | use auth::{UManager, check_auth}; 31 | 32 | // Config 33 | mod state; 34 | use state::{Config, AppState}; 35 | 36 | // Utils 37 | mod utils; 38 | use utils::*; 39 | 40 | pub static LOGGER_VAR: LazyLock = LazyLock::new(|| { 41 | var(LOGGER_ENV).unwrap_or(String::from("info")) 42 | }); 43 | pub static CONFIG_VAR: LazyLock = LazyLock::new(|| { 44 | var(CONFIG_ENV).unwrap_or(String::from("Config.toml")) 45 | }); 46 | pub static LOGS_VAR: LazyLock = LazyLock::new(|| { 47 | var(LOGS_ENV).unwrap_or(String::from("logs")) 48 | }); 49 | pub static ASSETS_VAR: LazyLock = LazyLock::new(|| { 50 | var(ASSETS_ENV).unwrap_or(String::from("data/assets")) 51 | }); 52 | pub static AVATARS_VAR: LazyLock = LazyLock::new(|| { 53 | var(AVATARS_ENV).unwrap_or(String::from("data/avatars")) 54 | }); 55 | 56 | #[tokio::main] 57 | async fn main() -> Result<()> { 58 | // 1. Set up env 59 | let _ = dotenvy::dotenv(); 60 | 61 | // 2. Set up logging 62 | let file_appender = tracing_appender::rolling::never(&*LOGS_VAR, get_log_file(&LOGS_VAR)); 63 | let timer = ChronoLocal::new(String::from("%Y-%m-%dT%H:%M:%S%.3f%:z")); 64 | 65 | let file_layer = fmt::layer() 66 | .with_ansi(false) // Disable ANSI colors for file logs 67 | .with_timer(timer.clone()) 68 | .pretty() 69 | .with_writer(file_appender); 70 | 71 | // Create a layer for the terminal 72 | let terminal_layer = fmt::layer() 73 | .with_ansi(true) 74 | .with_timer(timer) 75 | .pretty() 76 | .with_writer(std::io::stdout); 77 | 78 | // Combine the layers and set the global subscriber 79 | tracing_subscriber::registry() 80 | .with(EnvFilter::from(&*LOGGER_VAR)) 81 | .with(file_layer) 82 | .with(terminal_layer) 83 | .init(); 84 | 85 | // std::panic::set_hook(Box::new(panic_hook)); 86 | let prev_hook = std::panic::take_hook(); 87 | std::panic::set_hook(Box::new(move |panic_info| { 88 | panic_hook(panic_info); 89 | prev_hook(panic_info); 90 | })); 91 | 92 | // 3. Display info about current instance and check updates 93 | tracing::info!("The Sculptor v{SCULPTOR_VERSION} ({REPOSITORY})"); 94 | 95 | match get_latest_version(REPOSITORY).await { 96 | Ok(latest_version) => { 97 | if latest_version > semver::Version::parse(SCULPTOR_VERSION).expect("SCULPTOR_VERSION does not match SemVer!") { 98 | tracing::info!("Available new v{latest_version}! Check https://github.com/{REPOSITORY}/releases"); 99 | } else { 100 | tracing::info!("Sculptor are up to date!"); 101 | } 102 | }, 103 | Err(e) => { 104 | tracing::error!("Can't fetch Sculptor updates due: {e:?}"); 105 | }, 106 | } 107 | 108 | // Creating avatars folder 109 | let path = PathBuf::from(&*AVATARS_VAR); 110 | if !path.exists() { 111 | fs::create_dir_all(path).await.expect("Can't create avatars folder!"); 112 | tracing::info!("Created avatars directory"); 113 | } 114 | 115 | // 4. Starting an app() that starts to serve. If app() returns true, the sculptor will be restarted. TODO: for future 116 | loop { 117 | if !app().await? { 118 | break; 119 | } 120 | } 121 | 122 | Ok(()) 123 | } 124 | 125 | async fn app() -> Result { 126 | // Config 127 | let config = Config::parse(CONFIG_VAR.clone().into()); 128 | let listen = config.listen.clone(); 129 | let limit = get_limit_as_bytes(config.limitations.max_avatar_size as usize); 130 | 131 | if config.assets_updater_enabled { 132 | // Force update assets if folder or hash file doesn't exists. 133 | if !(PathBuf::from(&*ASSETS_VAR).is_dir() && get_path_to_assets_hash().is_file()) { 134 | tracing::debug!("Removing broken assets..."); 135 | remove_assets().await 136 | } 137 | match get_commit_sha(FIGURA_ASSETS_COMMIT_URL).await { 138 | Ok(sha) => { 139 | if is_assets_outdated(&sha).await.unwrap_or_else(|e| {tracing::error!("Can't check assets state due: {:?}", e); false}) { 140 | remove_assets().await; 141 | match tokio::task::spawn_blocking(|| { download_assets() }).await.unwrap() { 142 | Err(e) => tracing::error!("Assets outdated! Can't download new version due: {:?}", e), 143 | Ok(_) => { 144 | match write_sha_to_file(&sha).await { 145 | Ok(_) => tracing::info!("Assets successfully updated!"), 146 | Err(e) => tracing::error!("Assets successfully updated! Can't create assets hash file due: {:?}", e), 147 | } 148 | } 149 | }; 150 | } else { tracing::info!("Assets are up to date!") } 151 | }, 152 | Err(e) => tracing::error!("Can't get assets last commit! Assets update check aborted due {:?}", e) 153 | } 154 | } 155 | 156 | // State 157 | let state = AppState { 158 | uptime: Instant::now(), 159 | user_manager: Arc::new(UManager::new()), 160 | session: Arc::new(DashMap::new()), 161 | subscribes: Arc::new(DashMap::new()), 162 | figura_versions: Arc::new(RwLock::new(None)), 163 | config: Arc::new(RwLock::new(config.clone())), 164 | }; 165 | 166 | // Automatic update of configuration/ban list while the server is running 167 | tokio::spawn(update_advanced_users( 168 | CONFIG_VAR.clone().into(), 169 | Arc::clone(&state.user_manager), 170 | Arc::clone(&state.session), 171 | Arc::clone(&state.config) 172 | )); 173 | // Blacklist auto update 174 | if config.mc_folder.exists() { 175 | tokio::spawn(update_bans_from_minecraft( 176 | state.config.read().await.mc_folder.clone(), 177 | Arc::clone(&state.user_manager), 178 | Arc::clone(&state.session) 179 | )); 180 | } 181 | 182 | let api = Router::new() 183 | .nest("//auth", api_auth::router()) // => /api//auth ¯\_(ツ)_/¯ 184 | .nest("//assets", api_assets::router()) 185 | .nest("/auth", api_auth::router()) 186 | .nest("/assets", api_assets::router()) 187 | .nest("/v1", api::sculptor::router(limit)) 188 | .route("/limits", get(api_info::limits)) 189 | .route("/version", get(api_info::version)) 190 | .route("/motd", get(api_info::motd)) 191 | .route("/equip", post(api_profile::equip_avatar)) 192 | .route("/{uuid}", get(api_profile::user_info)) 193 | .route("/{uuid}/avatar", get(api_profile::download_avatar)) 194 | .route("/avatar", put(api_profile::upload_avatar).layer(DefaultBodyLimit::max(limit))) 195 | .route("/avatar", delete(api_profile::delete_avatar)); 196 | 197 | let app = Router::new() 198 | .nest("/api", api) 199 | .route("/api/", get(check_auth)) 200 | .route("/ws", get(ws)) 201 | .merge(metrics::metrics_router(config.metrics_enabled)) 202 | .with_state(state) 203 | .layer(TraceLayer::new_for_http() 204 | // .on_request(|request: &axum::http::Request<_>, _span: &tracing::Span| { 205 | // // only for developing purposes 206 | // tracing::trace!(headers = ?request.headers(), "started processing request"); 207 | // }) 208 | .on_response(|response: &axum::http::Response<_>, latency: std::time::Duration, _span: &tracing::Span| { 209 | tracing::trace!(latency = ?latency, status = ?response.status(), "finished processing request"); 210 | }) 211 | .on_request(()) 212 | ) 213 | .layer(axum::middleware::from_fn(track_metrics)) 214 | .route("/health", get(|| async { "ok" })); 215 | 216 | let listener = tokio::net::TcpListener::bind(listen).await?; 217 | tracing::info!("Listening on {}", listener.local_addr()?); 218 | 219 | axum::serve(listener, app) 220 | .with_graceful_shutdown(shutdown_signal()) 221 | .await?; 222 | 223 | tracing::info!("Serve stopped."); 224 | Ok(false) 225 | } 226 | 227 | async fn shutdown_signal() { 228 | let ctrl_c = async { 229 | tokio::signal::ctrl_c() 230 | .await 231 | .expect("failed to install Ctrl+C handler"); 232 | }; 233 | #[cfg(unix)] 234 | let terminate = async { 235 | tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) 236 | .expect("failed to install signal handler") 237 | .recv() 238 | .await; 239 | }; 240 | #[cfg(not(unix))] 241 | let terminate = std::future::pending::<()>(); 242 | tokio::select! { 243 | () = ctrl_c => { 244 | tracing::info!("Ctrl+C signal received"); 245 | }, 246 | () = terminate => { 247 | tracing::info!("Terminate signal received"); 248 | }, 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/api/figura/websocket/handler.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | use axum::{body::Bytes, extract::{ws::{Message, WebSocket}, State}}; 3 | use dashmap::DashMap; 4 | use tokio::sync::{broadcast, mpsc}; 5 | use tracing::instrument; 6 | 7 | use crate::{auth::Userinfo, AppState}; 8 | 9 | use super::{AuthModeError, C2SMessage, RADError, RecvAndDecode, S2CMessage, SessionMessage, WSSession}; 10 | 11 | pub async fn initial( 12 | ws: axum::extract::WebSocketUpgrade, 13 | State(state): State 14 | ) -> axum::response::Response { 15 | ws.on_upgrade(|socket| handle_socket(socket, state)) 16 | } 17 | 18 | async fn handle_socket(mut ws: WebSocket, state: AppState) { 19 | // Trying authenticate & get user data or dropping connection 20 | match authenticate(&mut ws, &state).await { 21 | Ok(user) => { 22 | 23 | // Creating session & creating/getting channels 24 | let mut session = { 25 | let sub_workers_aborthandles = DashMap::new(); 26 | 27 | // Channel for receiving messages from internal functions. 28 | let (own_tx, own_rx) = mpsc::channel(32); 29 | state.session.insert(user.uuid, own_tx.clone()); 30 | 31 | // Channel for sending messages to subscribers 32 | let subs_tx = match state.subscribes.get(&user.uuid) { 33 | Some(tx) => tx.clone(), 34 | None => { 35 | tracing::debug!("[Subscribes] Can't find own subs channel for {}, creating new...", user.uuid); 36 | let (subs_tx, _) = broadcast::channel(32); 37 | state.subscribes.insert(user.uuid, subs_tx.clone()); 38 | subs_tx 39 | }, 40 | }; 41 | 42 | WSSession { user: user.clone(), own_tx, own_rx, subs_tx, sub_workers_aborthandles } 43 | }; 44 | 45 | // Starting main worker 46 | if let Err(kind) = main_worker(&mut session, &mut ws, &state).await { 47 | tracing::info!(error = %kind, nickname = %session.user.nickname, "Main worker exited"); 48 | } 49 | 50 | for (_, handle) in session.sub_workers_aborthandles { 51 | handle.abort(); 52 | } 53 | 54 | // Removing session data 55 | state.session.remove(&user.uuid); 56 | state.user_manager.remove(&user.uuid); 57 | }, 58 | Err(kind) => { 59 | tracing::info!(error = %kind, "Can't authenticate"); 60 | } 61 | } 62 | 63 | // Closing connection 64 | if let Err(kind) = ws.send(Message::Close(None)).await { tracing::trace!("[WebSocket] Closing fault: {}", kind) } 65 | } 66 | 67 | #[instrument(skip_all, fields(nickname = %session.user.nickname))] 68 | async fn main_worker(session: &mut WSSession, ws: &mut WebSocket, state: &AppState) -> anyhow::Result<()> { 69 | tracing::debug!("WebSocket control for {} is transferred to the main worker", session.user.nickname); 70 | loop { 71 | tokio::select! { 72 | external_msg = ws.recv_and_decode() => { 73 | 74 | // Getting a value or halt the worker without an error 75 | let external_msg = match external_msg { 76 | Ok(m) => m, 77 | Err(kind) => { 78 | match kind { 79 | RADError::Close(_) => return Ok(()), 80 | RADError::StreamClosed => return Ok(()), 81 | _ => return Err(kind.into()) 82 | } 83 | }, 84 | }; 85 | 86 | // Processing message 87 | match external_msg { 88 | C2SMessage::Token(_) => bail!("authentication passed, but the client sent the Token again"), 89 | C2SMessage::Ping(func_id, echo, data) => { 90 | let s2c_ping: Vec = S2CMessage::Ping(session.user.uuid, func_id, echo, data).into(); 91 | 92 | // Echo check 93 | if echo { 94 | ws.send(Message::Binary(s2c_ping.clone().into())).await? 95 | } 96 | // Sending to others 97 | let _ = session.subs_tx.send(s2c_ping); 98 | }, 99 | C2SMessage::Sub(uuid) => { 100 | tracing::debug!("[WebSocket] {} subscribes to {}", session.user.nickname, uuid); 101 | 102 | // Doesn't allow to subscribe to yourself 103 | if session.user.uuid != uuid { 104 | // Creates a channel to send pings to a subscriber if it can't find an existing one 105 | let rx = match state.subscribes.get(&uuid) { 106 | Some(tx) => tx.subscribe(), 107 | None => { 108 | let (tx, rx) = broadcast::channel(32); 109 | state.subscribes.insert(uuid, tx); 110 | rx 111 | }, 112 | }; 113 | let handle = tokio::spawn(sub_worker(session.own_tx.clone(), rx)).abort_handle(); 114 | session.sub_workers_aborthandles.insert(uuid, handle); 115 | } 116 | }, 117 | C2SMessage::Unsub(uuid) => { 118 | tracing::debug!("[WebSocket] {} unsubscribes from {}", session.user.nickname, uuid); 119 | 120 | match session.sub_workers_aborthandles.get(&uuid) { 121 | Some(handle) => handle.abort(), 122 | None => tracing::warn!("[WebSocket] {} was not subscribed.", session.user.nickname), 123 | }; 124 | }, 125 | } 126 | }, 127 | internal_msg = session.own_rx.recv() => { 128 | let internal_msg = internal_msg.ok_or(anyhow::anyhow!("Unexpected error! Session channel broken!"))?; 129 | match internal_msg { 130 | SessionMessage::Ping(msg) => { 131 | ws.send(Message::Binary(msg.into())).await? 132 | }, 133 | SessionMessage::Banned => { 134 | let _ = ban_action(ws).await 135 | .inspect_err( 136 | |kind| tracing::warn!("[WebSocket] Didn't get the ban message due to {}", kind) 137 | ); 138 | bail!("{} banned!", session.user.nickname) 139 | }, 140 | } 141 | } 142 | } 143 | } 144 | } 145 | 146 | async fn sub_worker(tx_main: mpsc::Sender, mut rx: broadcast::Receiver>) { 147 | loop { 148 | let msg = match rx.recv().await { 149 | Ok(m) => m, 150 | Err(kind) => { 151 | tracing::error!("[Subscribes_Worker] Broadcast error! {}", kind); 152 | return; 153 | }, 154 | }; 155 | match tx_main.send(SessionMessage::Ping(msg)).await { 156 | Ok(_) => (), 157 | Err(kind) => { 158 | tracing::error!("[Subscribes_Worker] Session error! {}", kind); 159 | return; 160 | }, 161 | } 162 | } 163 | } 164 | 165 | async fn authenticate(socket: &mut WebSocket, state: &AppState) -> Result { 166 | match socket.recv_and_decode().await { 167 | Ok(msg) => { 168 | match msg { 169 | C2SMessage::Token(token) => { 170 | let token = String::from_utf8(token.to_vec()).map_err(|_| AuthModeError::ConvertError)?; 171 | match state.user_manager.get(&token) { 172 | Some(user) => { 173 | if socket.send(Message::Binary(Bytes::from(Into::>::into(S2CMessage::Auth)))).await.is_err() { 174 | Err(AuthModeError::SendError) 175 | } else if !user.banned { 176 | Ok(user.clone()) 177 | } else { 178 | let _ = ban_action(socket).await 179 | .inspect_err( 180 | |kind| tracing::warn!("[WebSocket] Didn't get the ban message due to {}", kind) 181 | ); 182 | Err(AuthModeError::Banned(user.nickname.clone())) 183 | } 184 | }, 185 | None => { 186 | if socket.send( 187 | Message::Close(Some(axum::extract::ws::CloseFrame { code: 4000, reason: "Re-auth".into() })) 188 | ).await.is_err() { 189 | Err(AuthModeError::SendError) 190 | } else { 191 | Err(AuthModeError::AuthenticationFailure) 192 | } 193 | }, 194 | } 195 | }, 196 | _ => { 197 | Err(AuthModeError::UnauthorizedAction) 198 | } 199 | } 200 | }, 201 | Err(err) => { 202 | Err(AuthModeError::RecvError(err)) 203 | }, 204 | } 205 | } 206 | 207 | async fn ban_action(ws: &mut WebSocket) -> anyhow::Result<()> { 208 | ws.send(Message::Binary(Into::>::into(S2CMessage::Toast(2, "You're banned!".to_string(), None)).into())).await?; 209 | tokio::time::sleep(std::time::Duration::from_secs(6)).await; 210 | ws.send(Message::Close(Some(axum::extract::ws::CloseFrame { code: 4001, reason: "You're banned!".into() }))).await?; 211 | 212 | Ok(()) 213 | } -------------------------------------------------------------------------------- /src/auth/auth.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::{anyhow, Context}; 4 | use axum::{ 5 | extract::{FromRequestParts, OptionalFromRequestParts, State}, http::{request::Parts, StatusCode} 6 | }; 7 | use dashmap::DashMap; 8 | use thiserror::Error; 9 | use tracing::{debug, error, instrument, trace, warn}; 10 | use uuid::Uuid; 11 | 12 | use crate::{ApiError, ApiResult, AppState, TIMEOUT, USER_AGENT}; 13 | 14 | use super::types::*; 15 | 16 | // It's an extractor that pulls a token from the Header. 17 | #[derive(PartialEq, Debug)] 18 | pub struct Token(pub String); 19 | 20 | impl Token { 21 | pub async fn _check_auth(self, state: &AppState) -> ApiResult<()> { 22 | if let Some(user) = state.user_manager.get(&self.0) { 23 | if !user.banned { 24 | Ok(()) 25 | } else { 26 | Err(ApiError::Unauthorized) 27 | } 28 | } else { 29 | Err(ApiError::Unauthorized) 30 | } 31 | } 32 | } 33 | 34 | impl FromRequestParts for Token 35 | where 36 | S: Send + Sync, 37 | { 38 | type Rejection = StatusCode; 39 | 40 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 41 | let token = parts 42 | .headers 43 | .get("token") 44 | .and_then(|value| value.to_str().ok()); 45 | trace!(token = ?token); 46 | match token { 47 | Some(token) => Ok(Self(token.to_string())), 48 | None => Err(StatusCode::UNAUTHORIZED), 49 | } 50 | } 51 | } 52 | 53 | impl OptionalFromRequestParts for Token 54 | where 55 | S: Send + Sync, 56 | { 57 | type Rejection = StatusCode; // Not required 58 | 59 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { 60 | let token = parts 61 | .headers 62 | .get("token") 63 | .and_then(|value| value.to_str().ok()); 64 | trace!(token = ?token); 65 | Ok(token.map(|t| Self(t.to_string()))) 66 | } 67 | } 68 | // End Extractor 69 | 70 | // Work with external APIs 71 | /// Get UUID from JSON response 72 | fn get_id_json(json: &serde_json::Value) -> Result { 73 | trace!("json: {json:#?}"); // For debugging, we'll get to this later! 74 | let uuid = Uuid::parse_str(json.get("id").unwrap().as_str().unwrap())?; 75 | Ok(uuid) 76 | } 77 | 78 | #[derive(Debug, Error)] 79 | enum FetchError { 80 | #[error("invalid response code (expected 200), found {0}.\n Response: {1:#?}")] 81 | WrongResponse(u16, Result), 82 | #[error(transparent)] 83 | SendError(#[from] reqwest::Error), 84 | #[error(transparent)] 85 | Other(#[from] anyhow::Error), 86 | 87 | } 88 | 89 | async fn fetch_json( 90 | auth_provider: &AuthProvider, 91 | server_id: &str, 92 | username: &str, 93 | ) -> Result<(Uuid, AuthProvider), FetchError> { 94 | let client = reqwest::Client::builder().timeout(TIMEOUT).user_agent(USER_AGENT).build().unwrap(); 95 | let url = auth_provider.url.clone(); 96 | 97 | let res = client 98 | .get(url) 99 | .query(&[("serverId", server_id), ("username", username)]) 100 | .send() 101 | .await?; 102 | trace!("{res:?}"); 103 | match res.status().as_u16() { 104 | 200 => { 105 | let json = serde_json::from_str::(&res.text().await?).with_context(|| "Cant deserialize".to_string())?; 106 | let uuid = get_id_json(&json).with_context(|| "Cant get UUID".to_string())?; 107 | Ok((uuid, auth_provider.clone())) 108 | } 109 | _ => Err(FetchError::WrongResponse(res.status().as_u16(), res.text().await)), 110 | } 111 | } 112 | 113 | pub async fn has_joined( 114 | AuthProviders(authproviders): AuthProviders, 115 | server_id: &str, 116 | username: &str, 117 | ) -> anyhow::Result> { 118 | let (tx, mut rx) = tokio::sync::mpsc::channel(1); 119 | 120 | for provider in &authproviders { 121 | tokio::spawn(fetch_and_send( 122 | provider.clone(), 123 | server_id.to_string(), 124 | username.to_string(), 125 | tx.clone() 126 | )); 127 | } 128 | let mut errors = Vec::new(); // Counting fetches what returns errors 129 | let mut misses = Vec::new(); // Counting non OK results 130 | let mut prov_count: usize = authproviders.len(); 131 | while prov_count > 0 { 132 | if let Some(fetch_res) = rx.recv().await { 133 | match fetch_res { 134 | Ok(data) => return Ok(Some(data)), 135 | Err(err) => { 136 | match err { 137 | FetchError::WrongResponse(code, data) => misses.push((code, data)), 138 | FetchError::SendError(err) => errors.push(err.to_string()), 139 | FetchError::Other(err) => errors.push(err.to_string()), 140 | } 141 | }, 142 | } 143 | } else { 144 | error!("Unexpected behavior!"); 145 | return Err(anyhow!("Something went wrong...")) 146 | } 147 | prov_count -= 1; 148 | } 149 | 150 | // Choosing what error return 151 | 152 | // Returns if some internals errors occured 153 | if !errors.is_empty() { 154 | error!("Something wrong with your authentification providers!\nMisses: {misses:?}\nErrors: {errors:?}"); 155 | Err(anyhow::anyhow!("{:?}", errors)) 156 | 157 | } else { 158 | // Returning if user can't be authenticated 159 | debug!("Misses: {misses:?}"); 160 | Ok(None) 161 | } 162 | } 163 | 164 | async fn fetch_and_send( 165 | provider: AuthProvider, 166 | server_id: String, 167 | username: String, 168 | tx: tokio::sync::mpsc::Sender> 169 | ) { 170 | let _ = tx.send(fetch_json(&provider, &server_id, &username).await) 171 | .await.map_err( |err| trace!("fetch_and_send error [note: ok res returned and mpsc clossed]: {err:?}")); 172 | } 173 | 174 | // User manager 175 | #[derive(Debug, Clone)] 176 | pub struct UManager { 177 | /// Users with incomplete authentication 178 | pending: Arc>, // TODO: Add automatic purge 179 | /// Authenticated users TODO: Change name to sessions 180 | authenticated: Arc>, // 181 | /// Registered users 182 | registered: Arc>, 183 | } 184 | 185 | impl Default for UManager { 186 | fn default() -> Self { 187 | Self::new() 188 | } 189 | } 190 | 191 | impl UManager { 192 | pub fn new() -> Self { 193 | Self { 194 | pending: Arc::new(DashMap::new()), 195 | registered: Arc::new(DashMap::new()), 196 | authenticated: Arc::new(DashMap::new()), 197 | } 198 | } 199 | pub fn get_all_registered(&self) -> DashMap { 200 | self.registered.as_ref().clone() 201 | } 202 | pub fn get_all_authenticated(&self) -> DashMap { 203 | self.authenticated.as_ref().clone() 204 | } 205 | pub fn pending_insert(&self, server_id: String, username: String) { 206 | self.pending.insert(server_id, username); 207 | } 208 | pub fn pending_remove(&self, server_id: &str) -> Option<(String, String)> { 209 | self.pending.remove(server_id) 210 | } 211 | pub fn insert(&self, uuid: Uuid, token: String, userinfo: Userinfo) -> Result<(), ()> { 212 | // Check for the presence of an active session. 213 | if let Some(userinfo) = self.registered.get(&uuid) { 214 | if let Some(token) = &userinfo.token { 215 | if self.authenticated.contains_key(token) { 216 | warn!("Rejected attempt to create a second session for the same user!"); 217 | return Err(()) 218 | } 219 | debug!("`{}` already have token in registered profile (old token already removed from 'authenticated')", userinfo.nickname); 220 | } 221 | } 222 | 223 | // Adding a user 224 | self.authenticated.insert(token, uuid); 225 | self.insert_user(uuid, userinfo); 226 | Ok(()) 227 | } 228 | pub fn insert_user(&self, uuid: Uuid, userinfo: Userinfo) { 229 | // self.registered.insert(uuid, userinfo) 230 | let usercopy = userinfo.clone(); 231 | self.registered.entry(uuid) 232 | .and_modify(|exist| { 233 | if !userinfo.nickname.is_empty() { exist.nickname = userinfo.nickname }; 234 | if !userinfo.auth_provider.is_empty() { exist.auth_provider = userinfo.auth_provider }; 235 | if userinfo.rank != Userinfo::default().rank { exist.rank = userinfo.rank }; 236 | if userinfo.token.is_some() { exist.token = userinfo.token }; 237 | if userinfo.version != Userinfo::default().version { exist.version = userinfo.version }; 238 | exist.last_used = userinfo.last_used; 239 | }).or_insert(usercopy); 240 | } 241 | pub fn get( 242 | &self, 243 | token: &String, 244 | ) -> Option> { 245 | let uuid = self.authenticated.get(token)?; 246 | self.registered.get(uuid.value()) 247 | } 248 | pub fn get_by_uuid( 249 | &self, 250 | uuid: &Uuid, 251 | ) -> Option> { 252 | self.registered.get(uuid) 253 | } 254 | pub fn ban(&self, banned_user: &Userinfo) { 255 | self.registered.entry(banned_user.uuid) 256 | .and_modify(|exist| { 257 | exist.banned = true; 258 | }).or_insert(banned_user.clone()); 259 | } 260 | pub fn unban(&self, uuid: &Uuid) { 261 | if let Some(mut user) = self.registered.get_mut(uuid) { 262 | user.banned = false; 263 | }; 264 | } 265 | pub fn _is_authenticated(&self, token: &String) -> bool { 266 | self.authenticated.contains_key(token) 267 | } 268 | pub fn _is_registered(&self, uuid: &Uuid) -> bool { 269 | self.registered.contains_key(uuid) 270 | } 271 | pub fn is_banned(&self, uuid: &Uuid) -> bool { 272 | if let Some(user) = self.registered.get(uuid) { user.banned } else { false } 273 | } 274 | pub fn count_authenticated(&self) -> usize { 275 | self.authenticated.len() 276 | } 277 | pub fn remove(&self, uuid: &Uuid) { 278 | let token = self.registered.get(uuid).unwrap().token.clone().unwrap(); 279 | self.authenticated.remove(&token); 280 | } 281 | } 282 | // End of User manager 283 | 284 | #[instrument(skip_all)] 285 | pub async fn check_auth( 286 | token: Option, 287 | State(state): State, 288 | ) -> ApiResult<&'static str> { 289 | match token { 290 | Some(token) => { 291 | match state.user_manager.get(&token.0) { 292 | Some(user) => { 293 | if user.banned { 294 | debug!(nickname = user.nickname, status = "banned", "Token owner is banned"); 295 | Err(ApiError::Unauthorized) 296 | } else { 297 | debug!(nickname = user.nickname, status = "ok", "Token verified successfully"); 298 | Ok("ok") 299 | } 300 | } 301 | None => { 302 | debug!(token = token.0, status = "invalid", "Invalid token"); 303 | Err(ApiError::Unauthorized) 304 | } 305 | } 306 | } 307 | None => { 308 | debug!(status = "not provided", "Token not provided"); 309 | Err(ApiError::BadRequest) 310 | } 311 | } 312 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------