diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7e333c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +target +**/node_modules +**/dist +.git +.github +data +docs +launcher/src +launcher/src-tauri/icons +launcher/src-tauri/gen diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..504866a --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Copy to .env and adjust. Only ADMIN_PASSWORD really matters. +PANEL_PORT=8080 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-please +JWT_SECRET= +CURSEFORGE_API_KEY= +MAX_UPLOAD_MB=2048 +RUST_LOG=info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4976391 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + rust: + name: Rust (fmt, clippy, tests) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Install Tauri system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + - name: Placeholder launcher frontend (tauri::generate_context needs it) + run: mkdir -p launcher/dist && echo '' > launcher/dist/index.html + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - run: cargo test --workspace --locked + + web: + name: Web UIs (type-check + build) + runs-on: ubuntu-24.04 + strategy: + matrix: + app: [panel/web, launcher] + defaults: + run: + working-directory: ${{ matrix.app }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + cache-dependency-path: ${{ matrix.app }}/package-lock.json + - run: npm ci --no-audit --no-fund + - run: npm run check + - run: npm run build + + launcher-windows: + name: Launcher (Windows build check) + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + cache-dependency-path: launcher/package-lock.json + - run: npm ci --no-audit --no-fund + working-directory: launcher + - run: npm run build + working-directory: launcher + - run: cargo clippy -p scopenet-launcher --locked -- -D warnings + - run: cargo test -p scopenet-launcher -p scopenet-core -p scopenet-shared --locked + + engine-online: + name: Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) + runs-on: ubuntu-24.04 + # Talks to third-party servers; a hiccup there shouldn't block merges. + continue-on-error: true + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install games (vanilla, legacy, Fabric, Quilt, Forge, NeoForge) + run: cargo test -p scopenet-core --test online --locked -- --ignored --test-threads=2 --nocapture + + docker: + name: Docker image builds + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bc1742d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,165 @@ +name: Release + +# Publish a release by pushing a tag (`git tag v0.2.0 && git push origin v0.2.0`) +# or from the Actions tab → Release → Run workflow (enter a version). +# +# Optional repository variables (Settings → Secrets and variables → Actions → Variables): +# PANEL_URL https://panel.example.com — baked into the launcher as the default server +# LOCK_PANEL "1" to stop players from changing the server +# LAUNCHER_NAME Installer / window name (default "ScopeNet Launcher") + +on: + push: + tags: ['v*.*.*'] + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 0.2.0)' + required: true + +permissions: + contents: write + packages: write + +env: + CARGO_TERM_COLOR: always + +jobs: + version: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.v.outputs.version }} + tag: ${{ steps.v.outputs.tag }} + steps: + - id: v + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then v="${{ inputs.version }}"; else v="${GITHUB_REF_NAME}"; fi + v="${v#v}" + if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "::error::'$v' is not a semantic version"; exit 1; fi + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "tag=v$v" >> "$GITHUB_OUTPUT" + + launcher: + name: Windows installer + needs: version + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: release + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + cache-dependency-path: launcher/package-lock.json + + - name: Stamp version + shell: bash + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" Cargo.toml + node -e "const f='launcher/src-tauri/tauri.conf.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))" + node -e "const f='launcher/package.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))" + + - run: npm ci --no-audit --no-fund + working-directory: launcher + + - name: Build installer + working-directory: launcher + shell: bash + env: + SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }} + SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }} + SCOPENET_REPO: ${{ github.repository }} + LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }} + run: | + if [ -n "$LAUNCHER_NAME" ]; then + CONFIG=$(node -e "console.log(JSON.stringify({productName: process.env.LAUNCHER_NAME, app: {windows: [{...require('./src-tauri/tauri.conf.json').app.windows[0], title: process.env.LAUNCHER_NAME}]}}))") + npx tauri build --bundles nsis --config "$CONFIG" + else + npx tauri build --bundles nsis + fi + + - name: Collect installer + shell: bash + run: | + mkdir -p out + cp target/release/bundle/nsis/*-setup.exe out/ + ls -la out + + - uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: out/*-setup.exe + if-no-files-found: error + + panel-image: + name: Panel image (GHCR) + needs: version + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - name: Image name (GHCR needs lowercase) + run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITHUB_ENV" + - name: Stamp version + run: sed -i "0,/^version = \".*\"/s//version = \"${{ needs.version.outputs.version }}\"/" Cargo.toml + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=semver,pattern={{version}},value=${{ needs.version.outputs.tag }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.tag }} + type=raw,value=latest,enable=${{ !contains(needs.version.outputs.version, '-') }} + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + release: + name: GitHub release + needs: [version, launcher, panel-image] + runs-on: ubuntu-24.04 + steps: + - uses: actions/download-artifact@v4 + with: + name: windows-installer + path: dist + - name: Release notes + run: | + IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" + cat > notes.md <&2; exit 1 ;; \ + esac > /rust-target \ + && rustup target add "$(cat /rust-target)" +WORKDIR /src +COPY . . +# No --locked: release builds stamp the version into Cargo.toml. Dependencies +# stay pinned by Cargo.lock either way (CI enforces --locked). +RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry \ + --mount=type=cache,target=/src/target,id=scopenet-target-$TARGETARCH \ + cargo zigbuild --release -p scopenet-panel --target "$(cat /rust-target)" \ + && cp "target/$(cat /rust-target)/release/scopenet-panel" /scopenet-panel +# Data dir owned by the non-root runtime user. +RUN mkdir -p /out/data && chown 65532:65532 /out/data + +# ---- 3. Runtime -------------------------------------------------------------- +FROM scratch +LABEL org.opencontainers.image.title="ScopeNet Panel" \ + org.opencontainers.image.description="Admin panel for the ScopeNet Minecraft launcher" \ + org.opencontainers.image.source="https://github.com/scopeddlol/SCOPENET-MC" +COPY --from=build /scopenet-panel /scopenet-panel +COPY --from=web /src/panel/web/dist /web +COPY --from=build --chown=65532:65532 /out/data /data +ENV SCOPENET_BIND=0.0.0.0:8080 \ + SCOPENET_DATA_DIR=/data \ + SCOPENET_WEB_DIR=/web \ + RUST_LOG=info +USER 65532:65532 +VOLUME ["/data"] +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/scopenet-panel", "healthcheck"] +ENTRYPOINT ["/scopenet-panel"] diff --git a/README.md b/README.md index 961bebe..cdf4580 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,121 @@ -# SCOPENET-MC -A Windows/Linux Minecraft Launcher with a Docker-hosted admin panel. +
+ + + +# ScopeNet + +**A fast, fully customisable Minecraft launcher — and the self-hosted admin panel that controls it.** + +Brand it, publish vanilla versions or modpacks, manage players, and push changes to every launcher instantly. + +
+ +

+ Launcher home + Panel launcher designer +

+ +## What's inside + +| | | +|---|---| +| **Launcher** (Windows) | Tauri 2 + Rust + Svelte. Uses the system WebView2 instead of bundling Chromium, so the installer stays small and memory use low. | +| **Admin panel** (Docker) | Rust (Axum) + SQLite + Svelte. 14 MB image; measured ~2 MB RAM at idle. | +| **Engine** | `scopenet-core`: installs Minecraft, Java, Fabric/Quilt/Forge/NeoForge and launches the game. | + +### Launcher features + +- **Accounts** — panel accounts (username + password, offline-mode play), **Microsoft** sign-in, and local **offline** usernames. Tokens are encrypted with a key stored in Windows Credential Manager. +- **Instances** — every instance the admin publishes shows up in the sidebar; group-restricted ones only for the right players. +- **One-click play** — downloads the right **Java automatically**, the game, the mod loader and the modpack, with live speed/ETA. Files are shared between instances and verified by SHA-1. +- **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen. +- **Settings** — memory with a recommendation for your PC, resolution, fullscreen, what happens on launch, Java path, GC presets (G1 / ZGC), JVM args, **Minecraft keybind editor** (applied to every instance), custom accent colour, glass/animation toggles, UI scale, storage manager, per-instance overrides. +- **Quality of life** — game console, crash dialog with plain-English hints, repair files, offline launch from cache, self-updates from GitHub Releases, single-instance window, `Ctrl+Enter` to play. +- **Branding from the panel** — name, logo, colours, font, corner radius, glass, image/video background, news feed, social links, custom CSS. No rebuild needed. + +### Admin panel features + +- **Instances** — pick a Minecraft version + loader, or import a modpack from **Modrinth**, **CurseForge** or a **.zip** (`.mrpack`, CurseForge export, or any instance folder). Upload extra mods/configs. CurseForge mods that block third-party downloads are flagged with a download link. +- **Access control** — public, signed-in players, or specific **groups**. +- **Players** — create/approve/disable accounts, roles, groups; sign-ups closed / approval / open. +- **Launcher design** — every branding option with a **live preview** and 7 colour presets. +- **Dashboard** — launches per day, active players, popular instances, recent activity. + +

+ + + +

+ +## Quick start + +### 1. Run the panel + +```bash +curl -O https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/docker-compose.yml +curl -o .env https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/.env.example +# edit .env → set ADMIN_PASSWORD +docker compose up -d +``` + +Open `http://your-server:8080`, sign in as `admin`, then: + +1. **Launcher design** → name, logo, colours, news. +2. **Instances** → *New instance* → a version, or a Modrinth / CurseForge / .zip modpack. +3. **Settings** → how players sign in. + +> Put the panel behind HTTPS (Caddy, Traefik, nginx, Cloudflare Tunnel…) before sharing it. See [docs/admin-guide.md](docs/admin-guide.md). + +### 2. Build your branded launcher + +1. In GitHub → **Settings → Secrets and variables → Actions → Variables**, add: + - `PANEL_URL` = `https://panel.example.com` (players connect automatically) + - `LOCK_PANEL` = `1` (optional: hide the "change server" option) + - `LAUNCHER_NAME` = `MyServer Launcher` (optional) +2. **Actions → Release → Run workflow**, enter a version like `1.0.0` — or push a tag `v1.0.0`. + +The workflow builds the **Windows installer**, pushes the **panel image to GHCR** (`ghcr.io//scopenet-mc-panel`, amd64 + arm64) and publishes a **GitHub release**. Installed launchers pick up new releases automatically. + +> The GHCR package is private by default the first time — make it public under your GitHub profile → Packages if you want `docker pull` without logging in. + +## Repository layout + +``` +crates/shared Wire types shared by launcher and panel (manifest, branding, auth) +crates/core Launcher engine: Mojang/Java/loaders, file sync, launch, MS auth, ping +panel/server Admin panel API (Axum + SQLite) +panel/web Admin panel UI (Svelte 5) +launcher/ Desktop launcher UI (Svelte 5) +launcher/src-tauri Desktop launcher shell (Tauri 2) +Dockerfile Panel image (static binary on scratch) +docker-compose.yml Panel deployment +.github/workflows CI + release (installer, GitHub release, GHCR image) +``` + +## Documentation + +- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups +- [Microsoft sign-in setup](docs/microsoft-auth.md) — Azure app registration +- [Architecture](docs/architecture.md) — how the pieces fit, API reference +- [Development](docs/development.md) — running everything locally, tests + +## Configuration reference + +**Panel (environment variables)** + +| Variable | Default | | +|---|---|---| +| `ADMIN_USERNAME` | `admin` | First admin account (created on first start) | +| `ADMIN_PASSWORD` | random, printed in logs | First admin password | +| `JWT_SECRET` | auto-generated in `/data` | Signs login tokens | +| `CURSEFORGE_API_KEY` | – | Also settable in the panel | +| `MAX_UPLOAD_MB` | `2048` | Largest modpack upload | +| `SCOPENET_BIND` | `0.0.0.0:8080` | Listen address | +| `SCOPENET_DATA_DIR` | `/data` | Database, hosted files, uploads | + +**Launcher (build-time, via repository variables)** — `PANEL_URL`, `LOCK_PANEL`, `LAUNCHER_NAME`. + +## Notes + +- Minecraft is a trademark of Mojang Studios. ScopeNet is not affiliated with Mojang or Microsoft. Offline mode is intended for your own community servers. +- No license has been chosen yet — add a `LICENSE` file before distributing. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index ff5a7cc..c240206 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -3,7 +3,6 @@ name = "scopenet-core" description = "Minecraft install / launch engine used by the ScopeNet launcher (and meta lookups in the panel)" version.workspace = true edition.workspace = true -license.workspace = true [dependencies] scopenet-shared.workspace = true diff --git a/crates/core/src/assets.rs b/crates/core/src/assets.rs index eda7a66..44dbe08 100644 --- a/crates/core/src/assets.rs +++ b/crates/core/src/assets.rs @@ -33,8 +33,7 @@ pub fn object_path(layout: &Layout, hash: &str) -> PathBuf { pub async fn load_index(client: &reqwest::Client, layout: &Layout, index: &AssetIndexRef) -> Result { let path = layout.assets().join("indexes").join(format!("{}.json", index.id)); if !http::file_ok(&path, index.sha1.as_deref(), index.size, true) { - http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {}) - .await?; + http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {}).await?; } Ok(serde_json::from_slice(&std::fs::read(&path)?)?) } @@ -64,11 +63,7 @@ pub fn materialize_legacy(layout: &Layout, index_id: &str, index: &AssetIndex, g if !index.is_virtual && !index.map_to_resources { return Ok(None); } - let target = if index.map_to_resources { - game_dir.join("resources") - } else { - layout.assets().join("virtual").join(index_id) - }; + let target = if index.map_to_resources { game_dir.join("resources") } else { layout.assets().join("virtual").join(index_id) }; for (name, obj) in &index.objects { let Some(dest) = crate::paths::safe_join(&target, name) else { continue }; if dest.exists() { diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index 9d501b2..a9967f7 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -129,10 +129,8 @@ async fn try_download(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn F tokio::fs::create_dir_all(dir).await?; } let resp = client.get(&dl.url).send().await?.error_for_status()?; - let part = dl.dest.with_extension(format!( - "{}part", - dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default() - )); + let part = + dl.dest.with_extension(format!("{}part", dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default())); let mut file = tokio::fs::File::create(&part).await?; let mut hasher = Sha1::new(); let mut stream = resp.bytes_stream(); diff --git a/crates/core/src/install.rs b/crates/core/src/install.rs index 09a4fda..843f80f 100644 --- a/crates/core/src/install.rs +++ b/crates/core/src/install.rs @@ -47,13 +47,8 @@ impl Installed { pub fn classpath(&self, layout: &Layout) -> Vec { let libs_dir = layout.libraries(); let mut seen = std::collections::HashSet::new(); - let mut cp: Vec = self - .libraries - .iter() - .filter(|l| l.classpath) - .map(|l| l.file(&libs_dir)) - .filter(|p| seen.insert(p.clone())) - .collect(); + let mut cp: Vec = + self.libraries.iter().filter(|l| l.classpath).map(|l| l.file(&libs_dir)).filter(|p| seen.insert(p.clone())).collect(); cp.push(self.client_jar.clone()); cp } @@ -69,11 +64,8 @@ async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Re return Ok(serde_json::from_slice(&bytes)?); } }; - let entry = manifest - .versions - .iter() - .find(|v| v.id == mc) - .ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?; + let entry = + manifest.versions.iter().find(|v| v.id == mc).ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?; if !http::file_ok(&path, entry.sha1.as_deref(), None, true) { http::download_one(client, &Download::new(entry.url.clone(), path.clone(), entry.sha1.clone(), None), &|_| {}).await?; } @@ -82,11 +74,8 @@ async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Re fn library_downloads(libs: &[ResolvedLib], layout: &Layout, deep: bool) -> Vec { let dir = layout.libraries(); - let mut out: Vec = libs - .iter() - .filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep)) - .filter_map(|l| l.download(&dir)) - .collect(); + let mut out: Vec = + libs.iter().filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep)).filter_map(|l| l.download(&dir)).collect(); out.sort_by(|a, b| a.dest.cmp(&b.dest)); out.dedup_by(|a, b| a.dest == b.dest); out @@ -146,7 +135,11 @@ pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSp Loader::Vanilla => None, Loader::Fabric | Loader::Quilt => { let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap(); - progress::stage(reporter, Stage::Loader, format!("Installing {} {lv}", if spec.loader == Loader::Fabric { "Fabric" } else { "Quilt" })); + progress::stage( + reporter, + Stage::Loader, + format!("Installing {} {lv}", if spec.loader == Loader::Fabric { "Fabric" } else { "Quilt" }), + ); Some(loaders::fabric::profile(client, layout, spec.loader, mc, &lv).await?) } Loader::Forge | Loader::NeoForge => { @@ -204,8 +197,8 @@ pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSp }; let assets_root = layout.assets(); - let game_assets = assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)? - .unwrap_or_else(|| assets_root.clone()); + let game_assets = + assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)?.unwrap_or_else(|| assets_root.clone()); Ok(Installed { java: java_windowed, diff --git a/crates/core/src/java.rs b/crates/core/src/java.rs index e917de1..5f94ac0 100644 --- a/crates/core/src/java.rs +++ b/crates/core/src/java.rs @@ -86,7 +86,11 @@ pub fn sibling_exe(java: &Path, windowed: bool) -> PathBuf { } let name = if windowed { "javaw.exe" } else { "java.exe" }; let candidate = java.with_file_name(name); - if candidate.exists() { candidate } else { java.to_path_buf() } + if candidate.exists() { + candidate + } else { + java.to_path_buf() + } } /// Make sure the Mojang runtime `component` (e.g. `java-runtime-delta`) is @@ -102,15 +106,14 @@ pub async fn ensure_runtime( let marker = dir.join(".scopenet-runtime"); let installed = std::fs::read_to_string(&marker).ok(); - let index: HashMap>> = - match http::get_json(client, RUNTIME_INDEX).await { - Ok(i) => i, - Err(e) if installed.is_some() && java_exe(&dir, false).exists() => { - tracing::warn!("java index unreachable ({e}); using installed {component}"); - return Ok(dir); - } - Err(e) => return Err(e).context("fetching Java runtime index"), - }; + let index: HashMap>> = match http::get_json(client, RUNTIME_INDEX).await { + Ok(i) => i, + Err(e) if installed.is_some() && java_exe(&dir, false).exists() => { + tracing::warn!("java index unreachable ({e}); using installed {component}"); + return Ok(dir); + } + Err(e) => return Err(e).context("fetching Java runtime index"), + }; let entry = index .get(platform_key()) .and_then(|p| p.get(component)) @@ -188,7 +191,11 @@ pub fn parse_java_version(text: &str) -> Option { let ver = &rest[..rest.find('"')?]; let mut parts = ver.split(['.', '_', '-', '+']); let first: u32 = parts.next()?.parse().ok()?; - if first == 1 { parts.next()?.parse().ok() } else { Some(first) } + if first == 1 { + parts.next()?.parse().ok() + } else { + Some(first) + } } #[cfg(test)] diff --git a/crates/core/src/launch.rs b/crates/core/src/launch.rs index d6625ff..ee82876 100644 --- a/crates/core/src/launch.rs +++ b/crates/core/src/launch.rs @@ -167,12 +167,7 @@ pub struct Command { pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Command { let v = &installed.version; let sep = if cfg!(windows) { ";" } else { ":" }; - let classpath = installed - .classpath(layout) - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect::>() - .join(sep); + let classpath = installed.classpath(layout).iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join(sep); let quick_play = v.supports_quick_play(); let env = Env::current() @@ -259,11 +254,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co /// Java 9+ can read arguments from a file, which sidesteps Windows' /// 32k command-line limit on huge modpacks. fn write_argfile(path: &Path, args: &[String]) -> Result<()> { - let body = args - .iter() - .map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\""))) - .collect::>() - .join("\n"); + let body = args.iter().map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\""))).collect::>().join("\n"); std::fs::write(path, body)?; Ok(()) } @@ -340,7 +331,13 @@ mod tests { fn opts(join: bool) -> LaunchOptions { LaunchOptions { - auth: Auth { username: "Steve".into(), uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), access_token: "0".into(), user_type: "legacy".into(), xuid: None }, + auth: Auth { + username: "Steve".into(), + uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), + access_token: "0".into(), + user_type: "legacy".into(), + xuid: None, + }, game_dir: "/g".into(), memory_min_mb: 1024, memory_max_mb: 4096, diff --git a/crates/core/src/libraries.rs b/crates/core/src/libraries.rs index 5a86ecc..40673d3 100644 --- a/crates/core/src/libraries.rs +++ b/crates/core/src/libraries.rs @@ -37,7 +37,11 @@ impl ResolvedLib { } fn join_url(base: &str, path: &str) -> String { - if base.ends_with('/') { format!("{base}{path}") } else { format!("{base}/{path}") } + if base.ends_with('/') { + format!("{base}{path}") + } else { + format!("{base}/{path}") + } } pub fn resolve(libs: &[Library], env: &Env) -> Vec { @@ -56,11 +60,7 @@ pub fn resolve(libs: &[Library], env: &Env) -> Vec { if let Some(natives) = &lib.natives { if let Some(classifier) = natives.get(env.os) { let classifier = classifier.replace("${arch}", env.bits()); - let from_downloads = lib - .downloads - .as_ref() - .and_then(|d| d.classifiers.as_ref()) - .and_then(|c| c.get(&classifier)); + let from_downloads = lib.downloads.as_ref().and_then(|d| d.classifiers.as_ref()).and_then(|c| c.get(&classifier)); let native_coord = coord.with_classifier(&classifier); let (path, url, sha1, size) = match from_downloads { Some(a) => ( @@ -207,10 +207,7 @@ mod tests { assert_eq!(libs.len(), 3); assert!(libs[0].native && !libs[0].classpath); assert_eq!(libs[0].path, "p/w64.jar"); - assert_eq!( - libs[1].url.as_deref(), - Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar") - ); + assert_eq!(libs[1].url.as_deref(), Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar")); assert!(libs[2].url.is_none(), "empty url = bundled in installer"); } } diff --git a/crates/core/src/loaders/forge.rs b/crates/core/src/loaders/forge.rs index f40314a..cb83c7c 100644 --- a/crates/core/src/loaders/forge.rs +++ b/crates/core/src/loaders/forge.rs @@ -253,11 +253,8 @@ async fn run_processors(ctx: &ForgeInstall<'_>, zip: &mut Zip, profile: &Install data.insert("LIBRARY_DIR".into(), libs_dir.to_string_lossy().into_owned()); let sep = if cfg!(windows) { ";" } else { ":" }; - let processors: Vec<&Processor> = profile - .processors - .iter() - .filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true)) - .collect(); + let processors: Vec<&Processor> = + profile.processors.iter().filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true)).collect(); let total = processors.len() as u32; for (i, proc) in processors.into_iter().enumerate() { diff --git a/crates/core/src/meta.rs b/crates/core/src/meta.rs index e8ca63b..0349b02 100644 --- a/crates/core/src/meta.rs +++ b/crates/core/src/meta.rs @@ -106,11 +106,9 @@ pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str) .collect() } Loader::Forge => { - let all: HashMap> = - get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?; - let promos: ForgePromos = get_json(client, &format!("{FORGE_FILES}/promotions_slim.json")) - .await - .unwrap_or(ForgePromos { promos: HashMap::new() }); + let all: HashMap> = get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?; + let promos: ForgePromos = + get_json(client, &format!("{FORGE_FILES}/promotions_slim.json")).await.unwrap_or(ForgePromos { promos: HashMap::new() }); let recommended = promos.promos.get(&format!("{mc}-recommended")).map(|v| format!("{mc}-{v}")); let mut list: Vec = all .get(mc) @@ -137,12 +135,7 @@ pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str) } /// Turn "latest"/"recommended"/empty into a concrete loader version. -pub async fn resolve_loader_version( - client: &reqwest::Client, - loader: Loader, - mc: &str, - requested: Option<&str>, -) -> Result> { +pub async fn resolve_loader_version(client: &reqwest::Client, loader: Loader, mc: &str, requested: Option<&str>) -> Result> { if loader == Loader::Vanilla { return Ok(None); } diff --git a/crates/core/src/msa.rs b/crates/core/src/msa.rs index 3e2c60b..533ef06 100644 --- a/crates/core/src/msa.rs +++ b/crates/core/src/msa.rs @@ -64,11 +64,7 @@ pub struct McSkin { } pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result { - let resp = client - .post(DEVICE_CODE_URL) - .form(&[("client_id", client_id), ("scope", SCOPE)]) - .send() - .await?; + let resp = client.post(DEVICE_CODE_URL).form(&[("client_id", client_id), ("scope", SCOPE)]).send().await?; if !resp.status().is_success() { let body = resp.text().await.unwrap_or_default(); bail!("Microsoft rejected the sign-in request: {body}"); @@ -116,12 +112,7 @@ pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result { let resp: TokenResponse = client .post(TOKEN_URL) - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", client_id), - ("refresh_token", refresh_token), - ("scope", SCOPE), - ]) + .form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)]) .send() .await? .json() @@ -210,22 +201,12 @@ async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: Stri .json() .await?; - let resp = client - .get("https://api.minecraftservices.com/minecraft/profile") - .bearer_auth(&mc.access_token) - .send() - .await?; + let resp = client.get("https://api.minecraftservices.com/minecraft/profile").bearer_auth(&mc.access_token).send().await?; if resp.status().as_u16() == 404 { bail!("this Microsoft account doesn't own Minecraft: Java Edition"); } let profile: McProfile = resp.error_for_status()?.json().await?; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; - Ok(MsaSession { - refresh_token, - mc_access_token: mc.access_token, - mc_expires_at: now + mc.expires_in, - profile, - xuid, - }) + Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid }) } diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs index 17d18fc..99dff59 100644 --- a/crates/core/src/paths.rs +++ b/crates/core/src/paths.rs @@ -49,12 +49,13 @@ impl Layout { /// Keep instance ids filesystem-safe no matter what the panel sends. pub fn sanitize_id(id: &str) -> String { - let s: String = id - .chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }) - .collect(); + let s: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect(); let s = s.trim_matches('.').to_string(); - if s.is_empty() { "_".into() } else { s } + if s.is_empty() { + "_".into() + } else { + s + } } /// Join a server-provided relative path onto `base`, refusing anything that @@ -72,7 +73,11 @@ pub fn safe_join(base: &Path, rel: &str) -> Option { p => out.push(p), } } - if out == base { None } else { Some(out) } + if out == base { + None + } else { + Some(out) + } } #[cfg(test)] diff --git a/crates/core/src/ping.rs b/crates/core/src/ping.rs index d5f4c0e..f1d497d 100644 --- a/crates/core/src/ping.rs +++ b/crates/core/src/ping.rs @@ -231,7 +231,8 @@ mod tests { #[test] fn flattens_motd() { - let v: serde_json::Value = serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap(); + let v: serde_json::Value = + serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap(); assert_eq!(flatten_chat(&v), "§b§lScope§rNet"); assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello"); } diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs index 5015c7e..352085f 100644 --- a/crates/core/src/rules.rs +++ b/crates/core/src/rules.rs @@ -57,7 +57,11 @@ impl Env { /// `${arch}` substitution used in legacy native classifiers. pub fn bits(&self) -> &'static str { - if self.arch == "x86" { "32" } else { "64" } + if self.arch == "x86" { + "32" + } else { + "64" + } } } @@ -126,10 +130,7 @@ mod tests { #[test] fn evaluates_rules() { - let rules: Vec = serde_json::from_str( - r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#, - ) - .unwrap(); + let rules: Vec = serde_json::from_str(r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#).unwrap(); assert!(allowed(&rules, &env("windows"))); assert!(!allowed(&rules, &env("osx"))); diff --git a/crates/core/src/servers_dat.rs b/crates/core/src/servers_dat.rs index 5bb4e9b..70918e7 100644 --- a/crates/core/src/servers_dat.rs +++ b/crates/core/src/servers_dat.rs @@ -46,7 +46,11 @@ pub fn upsert(game_dir: &Path, name: &str, address: &str) -> Result<()> { } pub fn format_address(host: &str, port: u16) -> String { - if port == 25565 { host.to_string() } else { format!("{host}:{port}") } + if port == 25565 { + host.to_string() + } else { + format!("{host}:{port}") + } } #[cfg(test)] @@ -74,10 +78,7 @@ mod tests { upsert(dir.path(), "Friend", "friend.net").unwrap(); upsert(dir.path(), "ScopeNet", "play.scopenet.gg").unwrap(); upsert(dir.path(), "ScopeNet SMP", "play.scopenet.gg").unwrap(); - assert_eq!( - names(dir.path()), - vec![("ScopeNet SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())] - ); + assert_eq!(names(dir.path()), vec![("ScopeNet SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())]); assert_eq!(format_address("a.b", 25565), "a.b"); assert_eq!(format_address("a.b", 25570), "a.b:25570"); } diff --git a/crates/core/src/sync.rs b/crates/core/src/sync.rs index a876adb..0da4dea 100644 --- a/crates/core/src/sync.rs +++ b/crates/core/src/sync.rs @@ -76,11 +76,7 @@ pub async fn sync( continue; }; wanted.insert(f.path.replace('\\', "/")); - let ok = if changed { - http::file_ok(&dest, Some(&f.sha1), Some(f.size), true) - } else { - dest.exists() - }; + let ok = if changed { http::file_ok(&dest, Some(&f.sha1), Some(f.size), true) } else { dest.exists() }; if !ok { downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size))); } diff --git a/crates/core/src/version.rs b/crates/core/src/version.rs index 31dd4b6..c92f423 100644 --- a/crates/core/src/version.rs +++ b/crates/core/src/version.rs @@ -170,11 +170,7 @@ impl VersionJson { } pub fn java_component(&self) -> String { - self.java_version - .as_ref() - .map(|j| j.component.clone()) - .filter(|c| !c.is_empty()) - .unwrap_or_else(|| "jre-legacy".into()) + self.java_version.as_ref().map(|j| j.component.clone()).filter(|c| !c.is_empty()).unwrap_or_else(|| "jre-legacy".into()) } /// True for 1.13+ style argument lists. diff --git a/crates/core/tests/online.rs b/crates/core/tests/online.rs index 6dbc830..880e13f 100644 --- a/crates/core/tests/online.rs +++ b/crates/core/tests/online.rs @@ -7,11 +7,20 @@ use scopenet_core::{progress, Layout}; use scopenet_shared::Loader; async fn run(mc: &str, loader: Loader) { - let root = std::env::var("SCOPENET_TEST_ROOT").map(std::path::PathBuf::from).unwrap_or_else(|_| std::env::temp_dir().join("scopenet-online")); + let root = + std::env::var("SCOPENET_TEST_ROOT").map(std::path::PathBuf::from).unwrap_or_else(|_| std::env::temp_dir().join("scopenet-online")); let layout = Layout::new(&root); let client = scopenet_core::http::client(); let game_dir = layout.instance_dir(&format!("{mc}-{}", loader.as_str())); - let spec = InstallSpec { mc_version: mc.into(), loader, loader_version: None, java_override: None, game_dir: game_dir.clone(), concurrency: 16, deep_verify: false }; + let spec = InstallSpec { + mc_version: mc.into(), + loader, + loader_version: None, + java_override: None, + game_dir: game_dir.clone(), + concurrency: 16, + deep_verify: false, + }; let installed = install(&client, &layout, &spec, &progress::noop()).await.unwrap_or_else(|e| panic!("{mc} {loader:?}: {e:#}")); for path in installed.classpath(&layout) { @@ -20,7 +29,13 @@ async fn run(mc: &str, loader: Loader) { assert!(installed.java.exists(), "java missing at {}", installed.java.display()); let opts = LaunchOptions { - auth: Auth { username: "CiBot".into(), uuid: scopenet_shared::offline_uuid("CiBot"), access_token: "0".into(), user_type: "legacy".into(), xuid: None }, + auth: Auth { + username: "CiBot".into(), + uuid: scopenet_shared::offline_uuid("CiBot"), + access_token: "0".into(), + user_type: "legacy".into(), + xuid: None, + }, game_dir, memory_min_mb: 512, memory_max_mb: 2048, diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index d07fd5f..ff08c23 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -3,7 +3,6 @@ name = "scopenet-shared" description = "Types shared between the ScopeNet launcher and admin panel" version.workspace = true edition.workspace = true -license.workspace = true [dependencies] serde.workspace = true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c149d7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +# ScopeNet admin panel +# +# cp .env.example .env # set ADMIN_PASSWORD at least +# docker compose up -d +# +# Then open http://localhost:8080 (or your domain behind a reverse proxy). +services: + panel: + image: ghcr.io/scopeddlol/scopenet-mc-panel:latest + # Or build from source: docker compose build + build: . + container_name: scopenet-panel + restart: unless-stopped + ports: + - "${PANEL_PORT:-8080}:8080" + environment: + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + # First start only: creates the admin account. If empty, a random + # password is printed in `docker compose logs panel`. + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + # Signs login tokens. Auto-generated into the data volume if empty. + JWT_SECRET: ${JWT_SECRET:-} + # Optional — can also be set in the panel's Settings page. + CURSEFORGE_API_KEY: ${CURSEFORGE_API_KEY:-} + MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048} + RUST_LOG: ${RUST_LOG:-info} + volumes: + - panel-data:/data + # Very small footprint: the panel idles at ~10 MB RAM. + deploy: + resources: + limits: + memory: 256M + +volumes: + panel-data: diff --git a/docs/admin-guide.md b/docs/admin-guide.md new file mode 100644 index 0000000..cc63fee --- /dev/null +++ b/docs/admin-guide.md @@ -0,0 +1,78 @@ +# Admin guide + +## Deploying the panel + +```bash +cp .env.example .env # set ADMIN_PASSWORD +docker compose up -d +docker compose logs -f panel # first start prints the admin account +``` + +Everything the panel stores lives in the `panel-data` volume (`/data`): + +| Path | What | +|---|---| +| `panel.db` | SQLite database (users, instances, settings, stats) | +| `files//` | Configs and uploads hosted for launchers | +| `uploads/` | Logos, backgrounds, icons | +| `jwt.secret` | Token signing key (unless `JWT_SECRET` is set) | + +**Backups:** stop the container (or use `sqlite3 panel.db ".backup backup.db"`) and copy the volume. + +**Updating:** `docker compose pull && docker compose up -d`. Database migrations run automatically. + +### HTTPS + +Launchers talk to the panel over the internet, so serve it over HTTPS. Example with Caddy: + +```caddyfile +panel.example.com { + reverse_proxy localhost:8080 +} +``` + +Large modpack uploads go through the proxy — raise its body-size limit if needed (nginx: `client_max_body_size 2g;`). + +## Instances + +An instance = one playable profile: a Minecraft version, an optional mod loader, files (mods, configs…) and an optional server. + +- **Version** — choose any Minecraft version and Vanilla / Fabric / Quilt / Forge / NeoForge. Leave the loader version empty for the latest recommended build (it's pinned when you save). +- **Modrinth** — search, pick a version, *Use*. Mods are downloaded by players straight from Modrinth's CDN; configs (`overrides/`) are hosted by the panel. +- **CurseForge** — needs a free API key from [console.curseforge.com](https://console.curseforge.com) (Settings → Integrations, or `CURSEFORGE_API_KEY`). Some authors block third-party downloads; those files show up under **Files** with a link — download them yourself and upload them into the same folder. +- **Upload .zip** — a `.mrpack`, a CurseForge export, or any zipped instance folder (with `mods/`, `config/`, … or a `.minecraft/` inside). For plain zips, pick the Minecraft version and loader. +- **Files** — add or remove individual files at any time. Files players add themselves are never touched; files you remove are deleted from players' instances on their next launch. + +Every save bumps the instance **revision**; launchers re-verify files when it changes, otherwise launching is instant. + +### Built-in server + +On the **Server** tab: name, address, port. + +- *Add to multiplayer list* writes the server into `servers.dat` (keeping servers players added). +- *Join automatically* connects on start (Quick Play on 1.20+, `--server` on older versions). + +Your server must allow the accounts you use: offline-mode (`online-mode=false`) for panel/offline accounts, or online-mode for Microsoft accounts. Protect offline-mode servers with a whitelist or an auth plugin. + +### Access + +- **Everyone** — any launcher, including offline and Microsoft accounts. +- **Signed-in players** — any panel account. +- **Specific groups** — members of the selected groups (create groups on the Players page). Admins see everything. + +## Players + +- **Sign-ups:** Settings → *Closed* (admins create accounts), *Needs approval*, or *Open*. +- Panel accounts play under their username with the offline-mode UUID an offline server computes, so inventories and permissions stay consistent. +- Disabling an account signs it out everywhere on the next request. +- Ten failed logins lock an account for five minutes. + +## Launcher design + +Everything on this page is pushed to launchers when they start or refresh — no reinstall. The **Features** tab lets you allow or block players from changing the theme or Java settings. **Custom CSS** is injected last; handy variables are `--accent`, `--accent-2`, `--surface`, `--radius`. + +## Releasing the launcher + +See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks GitHub Releases on start and offers the new installer. + +**Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..77a6b21 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,60 @@ +# Architecture + +``` +┌──────────────────────────┐ HTTPS (JSON) ┌──────────────────────────┐ +│ Launcher (Tauri) │ ─────────────────────────▶ │ Panel (Axum + SQLite) │ +│ Svelte UI ⇄ Rust shell │ manifest, instances, │ Svelte admin UI │ +│ │ │ auth, stats │ /files, /uploads │ +│ scopenet-core engine │ └──────────────────────────┘ +│ ├─ Mojang / Java │ ──▶ piston-meta, libraries.minecraft.net, resources +│ ├─ Fabric/Quilt/Forge │ ──▶ meta.fabricmc.net, maven.minecraftforge.net, … +│ └─ modpack files │ ──▶ Modrinth/CurseForge CDNs, panel /files +└──────────────────────────┘ +``` + +- **`crates/shared`** defines every JSON type exchanged, so the panel and launcher can't drift apart. +- **`crates/core`** has no UI dependencies. It's unit-tested, and CI runs real installs of vanilla, legacy, Fabric, Quilt, Forge and NeoForge against the live servers. +- The **panel** also uses `core` for version and loader lookups. + +## Launch pipeline + +1. Resolve the account (refreshing Microsoft tokens if needed). +2. Fetch the instance from the panel (cached for offline play). +3. `install`: vanilla version JSON → Java runtime (Mojang's own builds) → client jar, libraries, assets → loader profile (Fabric/Quilt), or replay the Forge/NeoForge installer's processors → merge → natives. +4. `sync`: download the admin's files; remove files the admin removed; never touch the player's own files. With an unchanged revision, only existence is checked. +5. Write `servers.dat` and keybinds, build the Java command (quick play / `--server`, memory, GC presets, `@argfile` for long classpaths), and start the game. +6. Stream logs to the console; on a crash, show the tail of the log with hints. + +## Disk layout (launcher) + +``` +%APPDATA%\net.scopenet.launcher\ + settings.json, accounts.json, secrets.bin (encrypted), manifest.json (cache) + minecraft\ + versions\ libraries\ assets\ runtimes\ natives\ cache\ + instances\\ ← game directory (saves, mods, options.txt, …) +``` + +## HTTP API + +Public (used by launchers). Send `Authorization: Bearer ` to see restricted instances: + +| Method | Path | | +|---|---|---| +| GET | `/api/v1/launcher/manifest` | Branding, auth options, visible instances | +| GET | `/api/v1/launcher/instances/{id}` | Instance + file list | +| POST | `/api/v1/launcher/events` | Launch stats | +| POST | `/api/v1/auth/login` · `/register` | Panel accounts | +| GET | `/api/v1/auth/me` | Current user | +| GET | `/files/{instance}/{path}` | Hosted instance files | +| GET | `/healthz` | Health check | + +Admin (`role = admin`): `/api/admin/{stats,users,groups,branding,settings,instances,uploads}` plus `instances/{id}/{import/modrinth,import/curseforge,import/upload,files,reset}` and meta/search proxies under `/api/admin/{meta,modrinth,curseforge}`. + +## Security notes + +- Passwords are hashed with Argon2id; tokens are HS256 JWTs (30 days); disabled accounts are rejected on every request. +- Server-provided paths are sanitised (`safe_join`) on both sides — no `..` or absolute paths. +- The panel never returns the CurseForge key; SVG uploads are refused (script risk). +- The launcher's CSP only allows scripts from the app itself; media may load from any HTTPS host (admin-chosen images). +- Hosted `/files` are public URLs. Don't put secrets in instance files. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..60e89a7 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,36 @@ +# Development + +**Requirements:** Rust (stable), Node 22. On Linux, also the Tauri deps: `libwebkit2gtk-4.1-dev librsvg2-dev libayatana-appindicator3-dev`. + +## Panel + +```bash +cd panel/web && npm install && npm run build && cd ../.. +ADMIN_PASSWORD=adminadmin cargo run -p scopenet-panel # http://localhost:8080 +# UI hot reload: (cd panel/web && npm run dev) # http://localhost:5173, proxies /api +``` + +## Launcher + +```bash +cd launcher && npm install +npm run tauri dev # full desktop app +npm run dev # UI only in a browser, with a mock backend (http://localhost:1420) +``` + +Browser mock scenarios: `?mock=setup`, `?mock=login`, `?mock=progress`. + +Build-time options (environment variables when building): `SCOPENET_PANEL_URL`, `SCOPENET_LOCK_PANEL=1`, `SCOPENET_REPO=owner/repo`. + +## Tests + +```bash +cargo test --workspace # unit + panel API tests +cargo test -p scopenet-core --test online -- --ignored # real installs (needs internet, ~2 GB) +(cd panel/web && npm run check) && (cd launcher && npm run check) +cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings +``` + +## Releasing + +Push a tag `vX.Y.Z` or run the **Release** workflow. It stamps the version into `Cargo.toml` and `tauri.conf.json`, builds the NSIS installer on Windows, pushes the multi-arch panel image to GHCR, and publishes the release. diff --git a/docs/microsoft-auth.md b/docs/microsoft-auth.md new file mode 100644 index 0000000..aff3913 --- /dev/null +++ b/docs/microsoft-auth.md @@ -0,0 +1,25 @@ +# Microsoft sign-in + +Microsoft accounts give players their real skin and let them join online-mode servers. It needs an Azure app registration that Mojang has approved for the Minecraft API. + +## 1. Register an app + +1. Go to [portal.azure.com](https://portal.azure.com) → **Microsoft Entra ID → App registrations → New registration**. +2. Name: your launcher's name. Supported account types: **Personal Microsoft accounts only**. +3. Redirect URI: leave empty. +4. After creating it, open **Authentication** → enable **Allow public client flows** (needed for the device-code flow) → Save. +5. Copy the **Application (client) ID**. + +## 2. Request Minecraft API access + +New apps can't call the Minecraft services API until Mojang approves them. Submit the form at with your client ID. Approval can take a while; until then sign-in fails with *"Minecraft services rejected the Xbox token"*. + +## 3. Enable it in the panel + +**Settings → Microsoft accounts** → turn on *Sign in with Microsoft* and paste the client ID. Launchers show the Microsoft tab on their next refresh. + +## How it works + +The launcher uses the OAuth **device-code flow**: it shows a short code, opens `microsoft.com/link`, and waits. The chain is Microsoft → Xbox Live → XSTS → Minecraft services → profile. The refresh token is stored encrypted on the player's PC and renewed silently before launches. The panel never sees Microsoft credentials. + +Common errors are translated for players (no Xbox profile yet, child account, game not owned). diff --git a/docs/screenshots/launcher-appearance.webp b/docs/screenshots/launcher-appearance.webp new file mode 100644 index 0000000..08ba17a Binary files /dev/null and b/docs/screenshots/launcher-appearance.webp differ diff --git a/docs/screenshots/launcher-controls.webp b/docs/screenshots/launcher-controls.webp new file mode 100644 index 0000000..1ee7a3b Binary files /dev/null and b/docs/screenshots/launcher-controls.webp differ diff --git a/docs/screenshots/launcher-home.webp b/docs/screenshots/launcher-home.webp new file mode 100644 index 0000000..4459cd3 Binary files /dev/null and b/docs/screenshots/launcher-home.webp differ diff --git a/docs/screenshots/launcher-install.webp b/docs/screenshots/launcher-install.webp new file mode 100644 index 0000000..a47ff4e Binary files /dev/null and b/docs/screenshots/launcher-install.webp differ diff --git a/docs/screenshots/launcher-login.webp b/docs/screenshots/launcher-login.webp new file mode 100644 index 0000000..11eddbe Binary files /dev/null and b/docs/screenshots/launcher-login.webp differ diff --git a/docs/screenshots/panel-dashboard.webp b/docs/screenshots/panel-dashboard.webp new file mode 100644 index 0000000..ff57cf8 Binary files /dev/null and b/docs/screenshots/panel-dashboard.webp differ diff --git a/docs/screenshots/panel-design.webp b/docs/screenshots/panel-design.webp new file mode 100644 index 0000000..e678f07 Binary files /dev/null and b/docs/screenshots/panel-design.webp differ diff --git a/docs/screenshots/panel-instance.webp b/docs/screenshots/panel-instance.webp new file mode 100644 index 0000000..4b7aea0 Binary files /dev/null and b/docs/screenshots/panel-instance.webp differ diff --git a/docs/screenshots/panel-players.webp b/docs/screenshots/panel-players.webp new file mode 100644 index 0000000..97fa7e6 Binary files /dev/null and b/docs/screenshots/panel-players.webp differ diff --git a/launcher/src-tauri/Cargo.toml b/launcher/src-tauri/Cargo.toml index a738ecb..38b726d 100644 --- a/launcher/src-tauri/Cargo.toml +++ b/launcher/src-tauri/Cargo.toml @@ -3,7 +3,6 @@ name = "scopenet-launcher" description = "ScopeNet Minecraft launcher" version.workspace = true edition.workspace = true -license.workspace = true [lib] name = "scopenet_launcher_lib" diff --git a/launcher/src-tauri/src/accounts.rs b/launcher/src-tauri/src/accounts.rs index bd8535c..a9a8923 100644 --- a/launcher/src-tauri/src/accounts.rs +++ b/launcher/src-tauri/src/accounts.rs @@ -45,10 +45,8 @@ impl AccountsFile { /// Add or replace (same kind + uuid + panel) and make it active. pub fn upsert(&mut self, account: Account) -> Account { - if let Some(existing) = self - .accounts - .iter_mut() - .find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url) + if let Some(existing) = + self.accounts.iter_mut().find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url) { let id = existing.id.clone(); *existing = Account { id: id.clone(), ..account }; diff --git a/launcher/src-tauri/src/game.rs b/launcher/src-tauri/src/game.rs index 9f1ef4e..4890d69 100644 --- a/launcher/src-tauri/src/game.rs +++ b/launcher/src-tauri/src/game.rs @@ -134,7 +134,15 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) - let auth = auth.unwrap(); report(Event::Stage { stage: Stage::Launching, label: "Starting Minecraft".into() }); - let pick = |a: u32, b: u32, c: u32| if a > 0 { a } else if b > 0 { b } else { c }; + let pick = |a: u32, b: u32, c: u32| { + if a > 0 { + a + } else if b > 0 { + b + } else { + c + } + }; let max = pick(ov.memory_max_mb, settings.memory_max_mb, inst.memory.max_mb).max(512); let min = pick(ov.memory_min_mb, settings.memory_min_mb, inst.memory.min_mb).min(max); let mut jvm = launch::split_args(&inst.jvm_args); @@ -200,7 +208,13 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) - // Stream logs to the UI in small batches (cheap IPC), keep a tail for crash reports. let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); - for stream in [child.stdout.take().map(|s| Box::new(s) as Box), child.stderr.take().map(|s| Box::new(s) as Box)].into_iter().flatten() { + for stream in [ + child.stdout.take().map(|s| Box::new(s) as Box), + child.stderr.take().map(|s| Box::new(s) as Box), + ] + .into_iter() + .flatten() + { let tx = tx.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stream).lines(); @@ -268,7 +282,8 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) - w.set_focus().ok(); } } - app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report }).ok(); + app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report }) + .ok(); emit_state(&app2, &id2, "idle", None); }); Ok(()) diff --git a/panel/server/Cargo.toml b/panel/server/Cargo.toml index 9a4a189..218967b 100644 --- a/panel/server/Cargo.toml +++ b/panel/server/Cargo.toml @@ -3,7 +3,6 @@ name = "scopenet-panel" description = "ScopeNet admin panel: manages instances, branding and players for the launcher" version.workspace = true edition.workspace = true -license.workspace = true [dependencies] scopenet-shared.workspace = true diff --git a/panel/server/src/auth.rs b/panel/server/src/auth.rs index c4f8be5..1503910 100644 --- a/panel/server/src/auth.rs +++ b/panel/server/src/auth.rs @@ -90,12 +90,10 @@ impl UserRow { } pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult> { - Ok(sqlx::query_scalar( - "SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name", - ) - .bind(user_id) - .fetch_all(&state.db) - .await?) + Ok(sqlx::query_scalar("SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name") + .bind(user_id) + .fetch_all(&state.db) + .await?) } pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult { diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index b3b06da..4215855 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -89,10 +89,7 @@ pub fn app(state: AppState) -> Router { .route("/healthz", axum::routing::get(routes::public::health)) .merge(routes::api(&state)) .nest_service("/files", ServeDir::new(state.cfg.files_dir())) - .nest_service( - "/uploads", - tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())), - ) + .nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir()))) .fallback_service(spa) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) diff --git a/panel/server/src/main.rs b/panel/server/src/main.rs index bc4a299..2fbdedf 100644 --- a/panel/server/src/main.rs +++ b/panel/server/src/main.rs @@ -30,7 +30,9 @@ async fn main() -> anyhow::Result<()> { } async fn shutdown() { - let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); }; + let ctrl_c = async { + tokio::signal::ctrl_c().await.ok(); + }; #[cfg(unix)] let term = async { if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { diff --git a/panel/server/src/packs.rs b/panel/server/src/packs.rs index 1587cb7..70f8a1b 100644 --- a/panel/server/src/packs.rs +++ b/panel/server/src/packs.rs @@ -56,14 +56,38 @@ fn read_json(zip: &mut Zip, name: &str) -> Optio /// Paths we never ship from a plain instance export. fn is_junk(rel: &str) -> bool { const DIRS: &[&str] = &[ - "logs/", "crash-reports/", "screenshots/", "versions/", "libraries/", "assets/", "natives/", "__MACOSX/", ".fabric/", ".scopenet/", + "logs/", + "crash-reports/", + "screenshots/", + "versions/", + "libraries/", + "assets/", + "natives/", + "__MACOSX/", + ".fabric/", + ".scopenet/", + ]; + const FILES: &[&str] = &[ + "usercache.json", + "usernamecache.json", + "launcher_profiles.json", + "launcher_accounts.json", + ".DS_Store", + "instance.cfg", + "mmc-pack.json", ]; - const FILES: &[&str] = &["usercache.json", "usernamecache.json", "launcher_profiles.json", "launcher_accounts.json", ".DS_Store", "instance.cfg", "mmc-pack.json"]; DIRS.iter().any(|d| rel.starts_with(d)) || FILES.iter().any(|f| rel == *f || rel.ends_with(&format!("/{f}"))) } /// Extract entries under `prefix` into the instance's file store. -fn extract_prefix(zip: &mut Zip, prefix: &str, dest_root: &Path, instance_id: &str, origin: &'static str, skip_junk: bool) -> AppResult> { +fn extract_prefix( + zip: &mut Zip, + prefix: &str, + dest_root: &Path, + instance_id: &str, + origin: &'static str, + skip_junk: bool, +) -> AppResult> { let mut out = Vec::new(); for i in 0..zip.len() { let mut entry = zip.by_index(i).map_err(|e| AppError::bad_request(format!("corrupt zip: {e}")))?; @@ -139,7 +163,14 @@ fn import_mrpack(zip: &mut Zip, index: MrIndex, dest_root: &Path, instance_id: & if safe_join(dest_root, &f.path).is_none() { continue; } - files.push(NewFile { path: f.path.replace('\\', "/"), url: url.clone(), sha1: sha1.clone(), size: f.file_size, origin: "pack", note: None }); + files.push(NewFile { + path: f.path.replace('\\', "/"), + url: url.clone(), + sha1: sha1.clone(), + size: f.file_size, + origin: "pack", + note: None, + }); } // client-overrides win over overrides. let mut overrides = extract_prefix(zip, "overrides/", dest_root, instance_id, "override", false)?; @@ -410,7 +441,9 @@ pub async fn fetch_curseforge(state: &AppState, mod_id: i64, file_id: i64) -> Ap } let info: Info = cf_get(state, &key, &format!("/mods/{mod_id}/files/{file_id}")).await?; let project: ModInfo = cf_get(state, &key, &format!("/mods/{mod_id}")).await?; - let url = info.download_url.ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?; + let url = info + .download_url + .ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?; let bytes = download_bytes(state, &url).await?; Ok((bytes, format!("CurseForge · {} ({})", project.name, info.display_name), project.logo.and_then(|l| l.thumbnail_url))) } @@ -464,7 +497,10 @@ pub async fn import_zip(state: &AppState, instance_id: &str, bytes: Vec, fal let (loader, loader_version) = primary.map(|l| parse_cf_loader(&mc, &l.id)).unwrap_or((Loader::Vanilla, None)); let mut files = resolve_cf_files(state, &manifest.files).await?; merge_files(&mut files, overrides); - return Ok((PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files }, "curseforge")); + return Ok(( + PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files }, + "curseforge", + )); } let (mut info, kind) = parsed.expect("parsed"); @@ -497,7 +533,14 @@ pub fn detect_root(names: &[String]) -> String { /// Replace the instance's pack/override files with `info.files`, keep files /// the admin uploaded by hand, update versions and bump the revision. -pub async fn apply(state: &AppState, instance_id: &str, info: &PackInfo, kind: &str, label: &str, source_ref: serde_json::Value) -> AppResult<()> { +pub async fn apply( + state: &AppState, + instance_id: &str, + info: &PackInfo, + kind: &str, + label: &str, + source_ref: serde_json::Value, +) -> AppResult<()> { let mut tx = state.db.begin().await?; sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND origin != 'upload'").bind(instance_id).execute(&mut *tx).await?; for f in &info.files { @@ -537,12 +580,8 @@ pub async fn apply(state: &AppState, instance_id: &str, info: &PackInfo, kind: & /// Delete stored files no longer referenced by the instance. pub async fn gc_files(state: &AppState, instance_id: &str) -> AppResult<()> { let root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id)); - let referenced: std::collections::HashSet = store::instance_files(state, instance_id) - .await? - .into_iter() - .filter(|f| f.url.starts_with("/files/")) - .map(|f| f.path) - .collect(); + let referenced: std::collections::HashSet = + store::instance_files(state, instance_id).await?.into_iter().filter(|f| f.url.starts_with("/files/")).map(|f| f.path).collect(); tokio::task::spawn_blocking(move || { fn walk(dir: &Path, root: &Path, keep: &std::collections::HashSet) { let Ok(entries) = std::fs::read_dir(dir) else { return }; diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs index c82d991..3153b94 100644 --- a/panel/server/src/routes/admin.rs +++ b/panel/server/src/routes/admin.rs @@ -35,12 +35,10 @@ pub async fn stats(_: AdminUser, State(state): State) -> AppResult, Option, String)> = sqlx::query_as("SELECT instance_id, username, created_at FROM events ORDER BY id DESC LIMIT 12").fetch_all(&state.db).await?; - let players_7d: i64 = sqlx::query_scalar( - "SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?", - ) - .bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339()) - .fetch_one(&state.db) - .await?; + let players_7d: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?") + .bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339()) + .fetch_one(&state.db) + .await?; // Fill in empty days so the chart is continuous. let mut days = Vec::new(); @@ -81,7 +79,8 @@ async fn view(state: &AppState, user: UserRow) -> AppResult { } pub async fn list_users(_: AdminUser, State(state): State) -> AppResult>> { - let users: Vec = sqlx::query_as("SELECT * FROM users ORDER BY status = 'pending' DESC, username COLLATE NOCASE").fetch_all(&state.db).await?; + let users: Vec = + sqlx::query_as("SELECT * FROM users ORDER BY status = 'pending' DESC, username COLLATE NOCASE").fetch_all(&state.db).await?; let mut out = Vec::with_capacity(users.len()); for u in users { out.push(view(&state, u).await?); @@ -158,7 +157,12 @@ pub async fn create_user(_: AdminUser, State(state): State, Json(input Ok(Json(view(&state, user).await?)) } -pub async fn update_user(AdminUser(me): AdminUser, State(state): State, Path(id): Path, Json(input): Json) -> AppResult> { +pub async fn update_user( + AdminUser(me): AdminUser, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> AppResult> { let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?") .bind(id) .fetch_optional(&state.db) @@ -166,10 +170,18 @@ pub async fn update_user(AdminUser(me): AdminUser, State(state): State .ok_or_else(|| AppError::not_found("user not found"))?; if let Some(password) = input.password.filter(|p| !p.is_empty()) { auth::validate_password(&password)?; - sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?").bind(auth::hash_password(&password)?).bind(id).execute(&state.db).await?; + sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") + .bind(auth::hash_password(&password)?) + .bind(id) + .execute(&state.db) + .await?; } if let Some(email) = input.email { - sqlx::query("UPDATE users SET email = ? WHERE id = ?").bind(Some(email.trim()).filter(|e| !e.is_empty())).bind(id).execute(&state.db).await?; + sqlx::query("UPDATE users SET email = ? WHERE id = ?") + .bind(Some(email.trim()).filter(|e| !e.is_empty())) + .bind(id) + .execute(&state.db) + .await?; } if let Some(role) = input.role { check_role(&role)?; @@ -358,7 +370,11 @@ async fn validated(state: &AppState, mut input: InstanceInput) -> AppResult, Json(input): Json) -> AppResult> { +pub async fn create_instance( + _: AdminUser, + State(state): State, + Json(input): Json, +) -> AppResult> { let input = validated(&state, input).await?; let id = store::unique_slug(&state, &input.name).await?; let now = crate::db::now(); @@ -409,7 +425,12 @@ async fn detail(state: &AppState, id: String) -> AppResult> Ok(Json(InstanceDetail { instance: row.to_admin(stats), files: store::instance_files(state, &id).await? })) } -pub async fn update_instance(_: AdminUser, State(state): State, Path(id): Path, Json(input): Json) -> AppResult> { +pub async fn update_instance( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> AppResult> { let existing = store::get_instance(&state, &id).await?; let input = validated(&state, input).await?; let mem = input.memory.unwrap_or_default(); @@ -473,7 +494,12 @@ pub struct ModrinthImport { project_id: Option, } -pub async fn import_modrinth(_: AdminUser, State(state): State, Path(id): Path, Json(req): Json) -> AppResult> { +pub async fn import_modrinth( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> AppResult> { let row = store::get_instance(&state, &id).await?; let (bytes, label, icon) = packs::fetch_modrinth(&state, &req.version_id).await?; let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?; @@ -488,7 +514,12 @@ pub struct CurseForgeImport { file_id: i64, } -pub async fn import_curseforge(_: AdminUser, State(state): State, Path(id): Path, Json(req): Json) -> AppResult> { +pub async fn import_curseforge( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> AppResult> { let row = store::get_instance(&state, &id).await?; let (bytes, label, icon) = packs::fetch_curseforge(&state, req.mod_id, req.file_id).await?; let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?; @@ -507,7 +538,12 @@ async fn set_icon_if_missing(state: &AppState, row: &store::InstanceRow, icon: O } /// Upload a .mrpack / CurseForge zip / plain instance zip. -pub async fn import_upload(_: AdminUser, State(state): State, Path(id): Path, mut form: Multipart) -> AppResult> { +pub async fn import_upload( + _: AdminUser, + State(state): State, + Path(id): Path, + mut form: Multipart, +) -> AppResult> { store::get_instance(&state, &id).await?; let mut bytes = None; let mut filename = String::from("upload.zip"); @@ -528,7 +564,10 @@ pub async fn import_upload(_: AdminUser, State(state): State, Path(id) let (mut info, kind) = packs::import_zip(&state, &id, bytes, fallback).await?; if kind == "zip" && info.loader != Loader::Vanilla { info.loader_version = - scopenet_core::meta::resolve_loader_version(&state.http, info.loader, &info.mc_version, info.loader_version.as_deref()).await.ok().flatten(); + scopenet_core::meta::resolve_loader_version(&state.http, info.loader, &info.mc_version, info.loader_version.as_deref()) + .await + .ok() + .flatten(); } let label = match kind { "modrinth" | "curseforge" if !info.name.is_empty() => format!("{} {}", info.name, info.version).trim().to_string(), @@ -540,7 +579,12 @@ pub async fn import_upload(_: AdminUser, State(state): State, Path(id) /// Add individual files (extra mods, configs, or a CurseForge mod that /// can't be downloaded automatically). -pub async fn upload_files(_: AdminUser, State(state): State, Path(id): Path, mut form: Multipart) -> AppResult> { +pub async fn upload_files( + _: AdminUser, + State(state): State, + Path(id): Path, + mut form: Multipart, +) -> AppResult> { store::get_instance(&state, &id).await?; let root = state.cfg.files_dir().join(sanitize_id(&id)); let mut folder = String::from("mods"); @@ -591,7 +635,12 @@ pub struct PathQuery { path: String, } -pub async fn delete_file(_: AdminUser, State(state): State, Path(id): Path, Query(q): Query) -> AppResult> { +pub async fn delete_file( + _: AdminUser, + State(state): State, + Path(id): Path, + Query(q): Query, +) -> AppResult> { sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND path = ?").bind(&id).bind(&q.path).execute(&state.db).await?; store::bump_revision(&state, &id).await?; packs::gc_files(&state, &id).await?; diff --git a/panel/server/src/routes/meta.rs b/panel/server/src/routes/meta.rs index a45df78..710e272 100644 --- a/panel/server/src/routes/meta.rs +++ b/panel/server/src/routes/meta.rs @@ -20,11 +20,7 @@ pub struct McVersion { pub async fn minecraft(_: AdminUser, State(state): State) -> AppResult> { let m = meta::mojang_manifest(&state.http).await?; - let versions: Vec = m - .versions - .into_iter() - .map(|v| McVersion { id: v.id, kind: v.kind, released: v.release_time }) - .collect(); + let versions: Vec = m.versions.into_iter().map(|v| McVersion { id: v.id, kind: v.kind, released: v.release_time }).collect(); Ok(Json(serde_json::json!({ "latest": m.latest, "versions": versions }))) } @@ -33,7 +29,12 @@ pub struct LoaderQuery { mc: String, } -pub async fn loaders(_: AdminUser, State(state): State, Path(loader): Path, Query(q): Query) -> AppResult>> { +pub async fn loaders( + _: AdminUser, + State(state): State, + Path(loader): Path, + Query(q): Query, +) -> AppResult>> { let loader = Loader::parse(&loader).unwrap_or_default(); Ok(Json(meta::loader_versions(&state.http, loader, &q.mc).await?)) } @@ -44,7 +45,11 @@ pub struct SearchQuery { q: String, } -pub async fn modrinth_search(_: AdminUser, State(state): State, Query(q): Query) -> AppResult> { +pub async fn modrinth_search( + _: AdminUser, + State(state): State, + Query(q): Query, +) -> AppResult> { let resp = state .http .get("https://api.modrinth.com/v2/search") @@ -57,16 +62,21 @@ pub async fn modrinth_search(_: AdminUser, State(state): State, Query( Ok(Json(resp.json().await.map_err(anyhow::Error::from)?)) } -pub async fn modrinth_versions(_: AdminUser, State(state): State, Path(project): Path) -> AppResult>> { +pub async fn modrinth_versions( + _: AdminUser, + State(state): State, + Path(project): Path, +) -> AppResult>> { let url = format!("https://api.modrinth.com/v2/project/{}/version", urlencode(&project)); Ok(Json(scopenet_core::http::get_json(&state.http, &url).await?)) } -pub async fn curseforge_search(_: AdminUser, State(state): State, Query(q): Query) -> AppResult> { - let path = format!( - "/mods/search?gameId=432&classId=4471&pageSize=24&sortField=2&sortOrder=desc&searchFilter={}", - urlencode(&q.q) - ); +pub async fn curseforge_search( + _: AdminUser, + State(state): State, + Query(q): Query, +) -> AppResult> { + let path = format!("/mods/search?gameId=432&classId=4471&pageSize=24&sortField=2&sortOrder=desc&searchFilter={}", urlencode(&q.q)); Ok(Json(packs::cf_raw_get(&state, &path).await?)) } diff --git a/panel/server/src/routes/public.rs b/panel/server/src/routes/public.rs index 84f4507..919223b 100644 --- a/panel/server/src/routes/public.rs +++ b/panel/server/src/routes/public.rs @@ -43,7 +43,11 @@ pub async fn manifest(State(state): State, MaybeUser(user): MaybeUser) })) } -pub async fn instance_manifest(State(state): State, MaybeUser(user): MaybeUser, Path(id): Path) -> AppResult> { +pub async fn instance_manifest( + State(state): State, + MaybeUser(user): MaybeUser, + Path(id): Path, +) -> AppResult> { let row = store::get_instance(&state, &id).await?; let groups = match &user { Some(u) => auth::user_groups(&state, u.id).await?, @@ -120,7 +124,11 @@ pub async fn me(State(state): State, AuthUser(user): AuthUser) -> AppR Ok(Json(auth::public_user(&state, &user).await?)) } -pub async fn event(State(state): State, MaybeUser(user): MaybeUser, Json(ev): Json) -> AppResult> { +pub async fn event( + State(state): State, + MaybeUser(user): MaybeUser, + Json(ev): Json, +) -> AppResult> { let kind = if ev.kind == "launch" { "launch" } else { "other" }; let name = user.map(|u| u.username).or(ev.username).map(|n| n.chars().take(32).collect::()); sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)") diff --git a/panel/server/src/store.rs b/panel/server/src/store.rs index b504632..0dc7da8 100644 --- a/panel/server/src/store.rs +++ b/panel/server/src/store.rs @@ -193,12 +193,11 @@ pub struct FileStats { } pub async fn file_stats(state: &AppState, instance_id: &str) -> AppResult { - let (count, size, missing): (i64, Option, Option) = sqlx::query_as( - "SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?", - ) - .bind(instance_id) - .fetch_one(&state.db) - .await?; + let (count, size, missing): (i64, Option, Option) = + sqlx::query_as("SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?") + .bind(instance_id) + .fetch_one(&state.db) + .await?; Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 }) } diff --git a/panel/server/tests/api.rs b/panel/server/tests/api.rs index f008af4..6f10460 100644 --- a/panel/server/tests/api.rs +++ b/panel/server/tests/api.rs @@ -63,7 +63,12 @@ fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec) for (k, v) in fields { write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap(); } - write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", file.0).unwrap(); + write!( + body, + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", + file.0 + ) + .unwrap(); body.extend_from_slice(file.1); write!(body, "\r\n--{boundary}--\r\n").unwrap(); (format!("multipart/form-data; boundary={boundary}"), body) @@ -105,9 +110,7 @@ async fn admin_only_routes_are_guarded() { assert_eq!(s, StatusCode::UNAUTHORIZED); let admin = t.login("admin", "supersecret").await; - let (s, v) = t - .call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))) - .await; + let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve")); @@ -188,12 +191,20 @@ async fn instance_visibility_and_zip_upload() { // Plain zip without version info → needs the fallback fields. let zip = zip_bytes(&[("MyPack/mods/cool.jar", b"jarjar"), ("MyPack/config/x.toml", b"a=1"), ("MyPack/logs/latest.log", b"junk")]); let (ct, body) = multipart(&[], ("pack.zip", &zip)); - let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let req = Request::post("/api/admin/instances/public/import/upload") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); let (s, v) = t.send(req).await; assert_eq!(s, StatusCode::BAD_REQUEST, "{v}"); let (ct, body) = multipart(&[("mc_version", "1.20.1"), ("loader", "vanilla")], ("pack.zip", &zip)); - let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let req = Request::post("/api/admin/instances/public/import/upload") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); let (s, v) = t.send(req).await; assert_eq!(s, StatusCode::OK, "{v}"); let paths: Vec<&str> = v["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect(); @@ -232,7 +243,11 @@ async fn mrpack_upload_sets_versions_and_overrides() { ("client-overrides/config/a.json", b"{\"client\":1}"), ]); let (ct, body) = multipart(&[], ("fab.mrpack", &zip)); - let req = Request::post("/api/admin/instances/pack/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let req = Request::post("/api/admin/instances/pack/import/upload") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); let (s, v) = t.send(req).await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v["instance"]["mc_version"], "1.21.1"); @@ -261,11 +276,19 @@ async fn branding_roundtrip_and_media_validation() { assert_eq!(m["branding"]["colors"]["accent"], "#ff5500"); let (ct, body) = multipart(&[], ("evil.svg", b"")); - let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let req = Request::post("/api/admin/uploads") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); let (s, _) = t.send(req).await; assert_eq!(s, StatusCode::BAD_REQUEST); let (ct, body) = multipart(&[], ("logo.png", b"\x89PNG")); - let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let req = Request::post("/api/admin/uploads") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); let (s, v) = t.send(req).await; assert_eq!(s, StatusCode::OK); assert!(v["url"].as_str().unwrap().starts_with("/uploads/")); diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..6677761 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 140 +use_small_heuristics = "Max"