Add Docker image, Compose, CI/CD workflows and docs

- Multi-arch (amd64/arm64) panel image: static musl binary on scratch,
  cross-compiled with cargo-zigbuild (no QEMU), non-root, healthcheck
- docker-compose.yml + .env.example for one-command deployment
- CI: fmt, clippy, tests, web type-checks, Windows launcher build,
  real-network installs of vanilla/Fabric/Quilt/Forge/NeoForge,
  Docker build
- Release: Windows NSIS installer with baked-in panel URL, GHCR image
  push and GitHub release (tag push or manual dispatch)
- README, admin guide, Microsoft auth setup, architecture, development
- rustfmt config and formatting pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 05:19:36 +00:00
1 parent 50eb1a0cac
commit 5dc8a4f2f1
54 files changed
+1019 -219

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
target
**/node_modules
**/dist
.git
.github
data
docs
launcher/src
launcher/src-tauri/icons
launcher/src-tauri/gen
+8
View File
@@ -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
+99
View File
@@ -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 '<!doctype html>' > 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
+165
View File
@@ -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 <<NOTES
## Downloads
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
**Admin panel (Docker):**
\`\`\`bash
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
\`\`\`
Or with Compose: set \`image: ${IMAGE}:${{ needs.version.outputs.version }}\` in \`docker-compose.yml\` and run \`docker compose up -d\`.
NOTES
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: ${{ needs.version.outputs.tag }}
target_commitish: ${{ github.sha }}
body_path: notes.md
generate_release_notes: true
prerelease: ${{ contains(needs.version.outputs.version, '-') }}
files: dist/*-setup.exe
+1
View File
@@ -5,6 +5,7 @@ build/
.svelte-kit/ .svelte-kit/
*.log *.log
.env .env
!.env.example
/data /data
/panel/server/data /panel/server/data
launcher/src-tauri/gen/schemas launcher/src-tauri/gen/schemas
-1
View File
@@ -5,7 +5,6 @@ members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT"
repository = "https://github.com/scopeddlol/SCOPENET-MC" repository = "https://github.com/scopeddlol/SCOPENET-MC"
[workspace.dependencies] [workspace.dependencies]
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1.7
#
# ScopeNet admin panel — a single static binary + the web UI on `scratch`.
# Multi-arch (amd64/arm64) is cross-compiled with cargo-zigbuild on the
# build machine's native arch, so no slow QEMU emulation is needed.
# ---- 1. Web UI ---------------------------------------------------------------
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src/panel/web
COPY panel/web/package.json panel/web/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY panel/web/ ./
RUN npm run build
# ---- 2. Server binary --------------------------------------------------------
FROM --platform=$BUILDPLATFORM rust:1-slim-bookworm AS build
ARG TARGETARCH
# zig (via the cargo-zigbuild wheel) is the cross C toolchain for musl targets.
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir --break-system-packages cargo-zigbuild
RUN case "$TARGETARCH" in \
amd64) echo x86_64-unknown-linux-musl ;; \
arm64) echo aarch64-unknown-linux-musl ;; \
*) echo "unsupported arch $TARGETARCH" >&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"]
+121 -2
View File
@@ -1,2 +1,121 @@
# SCOPENET-MC <div align="center">
A Windows/Linux Minecraft Launcher with a Docker-hosted admin panel.
<img src="launcher/src-tauri/icons/128x128.png" width="96" alt="" />
# 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.
</div>
<p align="center">
<img src="docs/screenshots/launcher-home.webp" width="49%" alt="Launcher home" />
<img src="docs/screenshots/panel-design.webp" width="49%" alt="Panel launcher designer" />
</p>
## 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.
<p align="center">
<img src="docs/screenshots/launcher-install.webp" width="32%" alt="" />
<img src="docs/screenshots/launcher-controls.webp" width="32%" alt="" />
<img src="docs/screenshots/panel-dashboard.webp" width="32%" alt="" />
</p>
## 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/<owner>/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.
-1
View File
@@ -3,7 +3,6 @@ name = "scopenet-core"
description = "Minecraft install / launch engine used by the ScopeNet launcher (and meta lookups in the panel)" description = "Minecraft install / launch engine used by the ScopeNet launcher (and meta lookups in the panel)"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true
[dependencies] [dependencies]
scopenet-shared.workspace = true scopenet-shared.workspace = true
+2 -7
View File
@@ -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<AssetIndex> { pub async fn load_index(client: &reqwest::Client, layout: &Layout, index: &AssetIndexRef) -> Result<AssetIndex> {
let path = layout.assets().join("indexes").join(format!("{}.json", index.id)); let path = layout.assets().join("indexes").join(format!("{}.json", index.id));
if !http::file_ok(&path, index.sha1.as_deref(), index.size, true) { 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), &|_| {}) http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {}).await?;
.await?;
} }
Ok(serde_json::from_slice(&std::fs::read(&path)?)?) 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 { if !index.is_virtual && !index.map_to_resources {
return Ok(None); return Ok(None);
} }
let target = if index.map_to_resources { let target = if index.map_to_resources { game_dir.join("resources") } else { layout.assets().join("virtual").join(index_id) };
game_dir.join("resources")
} else {
layout.assets().join("virtual").join(index_id)
};
for (name, obj) in &index.objects { for (name, obj) in &index.objects {
let Some(dest) = crate::paths::safe_join(&target, name) else { continue }; let Some(dest) = crate::paths::safe_join(&target, name) else { continue };
if dest.exists() { if dest.exists() {
+2 -4
View File
@@ -129,10 +129,8 @@ async fn try_download(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn F
tokio::fs::create_dir_all(dir).await?; tokio::fs::create_dir_all(dir).await?;
} }
let resp = client.get(&dl.url).send().await?.error_for_status()?; let resp = client.get(&dl.url).send().await?.error_for_status()?;
let part = dl.dest.with_extension(format!( let part =
"{}part", dl.dest.with_extension(format!("{}part", dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()));
dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()
));
let mut file = tokio::fs::File::create(&part).await?; let mut file = tokio::fs::File::create(&part).await?;
let mut hasher = Sha1::new(); let mut hasher = Sha1::new();
let mut stream = resp.bytes_stream(); let mut stream = resp.bytes_stream();
+13 -20
View File
@@ -47,13 +47,8 @@ impl Installed {
pub fn classpath(&self, layout: &Layout) -> Vec<PathBuf> { pub fn classpath(&self, layout: &Layout) -> Vec<PathBuf> {
let libs_dir = layout.libraries(); let libs_dir = layout.libraries();
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let mut cp: Vec<PathBuf> = self let mut cp: Vec<PathBuf> =
.libraries self.libraries.iter().filter(|l| l.classpath).map(|l| l.file(&libs_dir)).filter(|p| seen.insert(p.clone())).collect();
.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.push(self.client_jar.clone());
cp cp
} }
@@ -69,11 +64,8 @@ async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Re
return Ok(serde_json::from_slice(&bytes)?); return Ok(serde_json::from_slice(&bytes)?);
} }
}; };
let entry = manifest let entry =
.versions manifest.versions.iter().find(|v| v.id == mc).ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?;
.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) { 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?; 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<Download> { fn library_downloads(libs: &[ResolvedLib], layout: &Layout, deep: bool) -> Vec<Download> {
let dir = layout.libraries(); let dir = layout.libraries();
let mut out: Vec<Download> = libs let mut out: Vec<Download> =
.iter() libs.iter().filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep)).filter_map(|l| l.download(&dir)).collect();
.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.sort_by(|a, b| a.dest.cmp(&b.dest));
out.dedup_by(|a, b| a.dest == b.dest); out.dedup_by(|a, b| a.dest == b.dest);
out out
@@ -146,7 +135,11 @@ pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSp
Loader::Vanilla => None, Loader::Vanilla => None,
Loader::Fabric | Loader::Quilt => { Loader::Fabric | Loader::Quilt => {
let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap(); 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?) Some(loaders::fabric::profile(client, layout, spec.loader, mc, &lv).await?)
} }
Loader::Forge | Loader::NeoForge => { 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 assets_root = layout.assets();
let game_assets = assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)? let game_assets =
.unwrap_or_else(|| assets_root.clone()); assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)?.unwrap_or_else(|| assets_root.clone());
Ok(Installed { Ok(Installed {
java: java_windowed, java: java_windowed,
+18 -11
View File
@@ -86,7 +86,11 @@ pub fn sibling_exe(java: &Path, windowed: bool) -> PathBuf {
} }
let name = if windowed { "javaw.exe" } else { "java.exe" }; let name = if windowed { "javaw.exe" } else { "java.exe" };
let candidate = java.with_file_name(name); 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 /// 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 marker = dir.join(".scopenet-runtime");
let installed = std::fs::read_to_string(&marker).ok(); let installed = std::fs::read_to_string(&marker).ok();
let index: HashMap<String, HashMap<String, Vec<RuntimeEntry>>> = let index: HashMap<String, HashMap<String, Vec<RuntimeEntry>>> = match http::get_json(client, RUNTIME_INDEX).await {
match http::get_json(client, RUNTIME_INDEX).await { Ok(i) => i,
Ok(i) => i, Err(e) if installed.is_some() && java_exe(&dir, false).exists() => {
Err(e) if installed.is_some() && java_exe(&dir, false).exists() => { tracing::warn!("java index unreachable ({e}); using installed {component}");
tracing::warn!("java index unreachable ({e}); using installed {component}"); return Ok(dir);
return Ok(dir); }
} Err(e) => return Err(e).context("fetching Java runtime index"),
Err(e) => return Err(e).context("fetching Java runtime index"), };
};
let entry = index let entry = index
.get(platform_key()) .get(platform_key())
.and_then(|p| p.get(component)) .and_then(|p| p.get(component))
@@ -188,7 +191,11 @@ pub fn parse_java_version(text: &str) -> Option<u32> {
let ver = &rest[..rest.find('"')?]; let ver = &rest[..rest.find('"')?];
let mut parts = ver.split(['.', '_', '-', '+']); let mut parts = ver.split(['.', '_', '-', '+']);
let first: u32 = parts.next()?.parse().ok()?; 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)] #[cfg(test)]
+9 -12
View File
@@ -167,12 +167,7 @@ pub struct Command {
pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Command { pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Command {
let v = &installed.version; let v = &installed.version;
let sep = if cfg!(windows) { ";" } else { ":" }; let sep = if cfg!(windows) { ";" } else { ":" };
let classpath = installed let classpath = installed.classpath(layout).iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join(sep);
.classpath(layout)
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(sep);
let quick_play = v.supports_quick_play(); let quick_play = v.supports_quick_play();
let env = Env::current() 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' /// Java 9+ can read arguments from a file, which sidesteps Windows'
/// 32k command-line limit on huge modpacks. /// 32k command-line limit on huge modpacks.
fn write_argfile(path: &Path, args: &[String]) -> Result<()> { fn write_argfile(path: &Path, args: &[String]) -> Result<()> {
let body = args let body = args.iter().map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\""))).collect::<Vec<_>>().join("\n");
.iter()
.map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\"")))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, body)?; std::fs::write(path, body)?;
Ok(()) Ok(())
} }
@@ -340,7 +331,13 @@ mod tests {
fn opts(join: bool) -> LaunchOptions { fn opts(join: bool) -> LaunchOptions {
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(), game_dir: "/g".into(),
memory_min_mb: 1024, memory_min_mb: 1024,
memory_max_mb: 4096, memory_max_mb: 4096,
+7 -10
View File
@@ -37,7 +37,11 @@ impl ResolvedLib {
} }
fn join_url(base: &str, path: &str) -> String { 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<ResolvedLib> { pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
@@ -56,11 +60,7 @@ pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
if let Some(natives) = &lib.natives { if let Some(natives) = &lib.natives {
if let Some(classifier) = natives.get(env.os) { if let Some(classifier) = natives.get(env.os) {
let classifier = classifier.replace("${arch}", env.bits()); let classifier = classifier.replace("${arch}", env.bits());
let from_downloads = lib let from_downloads = lib.downloads.as_ref().and_then(|d| d.classifiers.as_ref()).and_then(|c| c.get(&classifier));
.downloads
.as_ref()
.and_then(|d| d.classifiers.as_ref())
.and_then(|c| c.get(&classifier));
let native_coord = coord.with_classifier(&classifier); let native_coord = coord.with_classifier(&classifier);
let (path, url, sha1, size) = match from_downloads { let (path, url, sha1, size) = match from_downloads {
Some(a) => ( Some(a) => (
@@ -207,10 +207,7 @@ mod tests {
assert_eq!(libs.len(), 3); assert_eq!(libs.len(), 3);
assert!(libs[0].native && !libs[0].classpath); assert!(libs[0].native && !libs[0].classpath);
assert_eq!(libs[0].path, "p/w64.jar"); assert_eq!(libs[0].path, "p/w64.jar");
assert_eq!( assert_eq!(libs[1].url.as_deref(), Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar"));
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"); assert!(libs[2].url.is_none(), "empty url = bundled in installer");
} }
} }
+2 -5
View File
@@ -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()); data.insert("LIBRARY_DIR".into(), libs_dir.to_string_lossy().into_owned());
let sep = if cfg!(windows) { ";" } else { ":" }; let sep = if cfg!(windows) { ";" } else { ":" };
let processors: Vec<&Processor> = profile let processors: Vec<&Processor> =
.processors profile.processors.iter().filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true)).collect();
.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; let total = processors.len() as u32;
for (i, proc) in processors.into_iter().enumerate() { for (i, proc) in processors.into_iter().enumerate() {
+4 -11
View File
@@ -106,11 +106,9 @@ pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str)
.collect() .collect()
} }
Loader::Forge => { Loader::Forge => {
let all: HashMap<String, Vec<String>> = let all: HashMap<String, Vec<String>> = get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?;
get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?; let promos: ForgePromos =
let promos: ForgePromos = get_json(client, &format!("{FORGE_FILES}/promotions_slim.json")) get_json(client, &format!("{FORGE_FILES}/promotions_slim.json")).await.unwrap_or(ForgePromos { promos: HashMap::new() });
.await
.unwrap_or(ForgePromos { promos: HashMap::new() });
let recommended = promos.promos.get(&format!("{mc}-recommended")).map(|v| format!("{mc}-{v}")); let recommended = promos.promos.get(&format!("{mc}-recommended")).map(|v| format!("{mc}-{v}"));
let mut list: Vec<LoaderVersion> = all let mut list: Vec<LoaderVersion> = all
.get(mc) .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. /// Turn "latest"/"recommended"/empty into a concrete loader version.
pub async fn resolve_loader_version( pub async fn resolve_loader_version(client: &reqwest::Client, loader: Loader, mc: &str, requested: Option<&str>) -> Result<Option<String>> {
client: &reqwest::Client,
loader: Loader,
mc: &str,
requested: Option<&str>,
) -> Result<Option<String>> {
if loader == Loader::Vanilla { if loader == Loader::Vanilla {
return Ok(None); return Ok(None);
} }
+4 -23
View File
@@ -64,11 +64,7 @@ pub struct McSkin {
} }
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> { pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
let resp = client let resp = client.post(DEVICE_CODE_URL).form(&[("client_id", client_id), ("scope", SCOPE)]).send().await?;
.post(DEVICE_CODE_URL)
.form(&[("client_id", client_id), ("scope", SCOPE)])
.send()
.await?;
if !resp.status().is_success() { if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
bail!("Microsoft rejected the sign-in request: {body}"); 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<MsaSession> { pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result<MsaSession> {
let resp: TokenResponse = client let resp: TokenResponse = client
.post(TOKEN_URL) .post(TOKEN_URL)
.form(&[ .form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)])
("grant_type", "refresh_token"),
("client_id", client_id),
("refresh_token", refresh_token),
("scope", SCOPE),
])
.send() .send()
.await? .await?
.json() .json()
@@ -210,22 +201,12 @@ async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: Stri
.json() .json()
.await?; .await?;
let resp = client let resp = client.get("https://api.minecraftservices.com/minecraft/profile").bearer_auth(&mc.access_token).send().await?;
.get("https://api.minecraftservices.com/minecraft/profile")
.bearer_auth(&mc.access_token)
.send()
.await?;
if resp.status().as_u16() == 404 { if resp.status().as_u16() == 404 {
bail!("this Microsoft account doesn't own Minecraft: Java Edition"); bail!("this Microsoft account doesn't own Minecraft: Java Edition");
} }
let profile: McProfile = resp.error_for_status()?.json().await?; 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; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
Ok(MsaSession { Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid })
refresh_token,
mc_access_token: mc.access_token,
mc_expires_at: now + mc.expires_in,
profile,
xuid,
})
} }
+11 -6
View File
@@ -49,12 +49,13 @@ impl Layout {
/// Keep instance ids filesystem-safe no matter what the panel sends. /// Keep instance ids filesystem-safe no matter what the panel sends.
pub fn sanitize_id(id: &str) -> String { pub fn sanitize_id(id: &str) -> String {
let s: String = id let s: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect();
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
let s = s.trim_matches('.').to_string(); 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 /// Join a server-provided relative path onto `base`, refusing anything that
@@ -72,7 +73,11 @@ pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
p => out.push(p), p => out.push(p),
} }
} }
if out == base { None } else { Some(out) } if out == base {
None
} else {
Some(out)
}
} }
#[cfg(test)] #[cfg(test)]
+2 -1
View File
@@ -231,7 +231,8 @@ mod tests {
#[test] #[test]
fn flattens_motd() { 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(&v), "§b§lScope§rNet");
assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello"); assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello");
} }
+6 -5
View File
@@ -57,7 +57,11 @@ impl Env {
/// `${arch}` substitution used in legacy native classifiers. /// `${arch}` substitution used in legacy native classifiers.
pub fn bits(&self) -> &'static str { 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] #[test]
fn evaluates_rules() { fn evaluates_rules() {
let rules: Vec<Rule> = serde_json::from_str( let rules: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#).unwrap();
r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#,
)
.unwrap();
assert!(allowed(&rules, &env("windows"))); assert!(allowed(&rules, &env("windows")));
assert!(!allowed(&rules, &env("osx"))); assert!(!allowed(&rules, &env("osx")));
+6 -5
View File
@@ -46,7 +46,11 @@ pub fn upsert(game_dir: &Path, name: &str, address: &str) -> Result<()> {
} }
pub fn format_address(host: &str, port: u16) -> String { 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)] #[cfg(test)]
@@ -74,10 +78,7 @@ mod tests {
upsert(dir.path(), "Friend", "friend.net").unwrap(); upsert(dir.path(), "Friend", "friend.net").unwrap();
upsert(dir.path(), "ScopeNet", "play.scopenet.gg").unwrap(); upsert(dir.path(), "ScopeNet", "play.scopenet.gg").unwrap();
upsert(dir.path(), "ScopeNet SMP", "play.scopenet.gg").unwrap(); upsert(dir.path(), "ScopeNet SMP", "play.scopenet.gg").unwrap();
assert_eq!( assert_eq!(names(dir.path()), vec![("ScopeNet SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())]);
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", 25565), "a.b");
assert_eq!(format_address("a.b", 25570), "a.b:25570"); assert_eq!(format_address("a.b", 25570), "a.b:25570");
} }
+1 -5
View File
@@ -76,11 +76,7 @@ pub async fn sync(
continue; continue;
}; };
wanted.insert(f.path.replace('\\', "/")); wanted.insert(f.path.replace('\\', "/"));
let ok = if changed { let ok = if changed { http::file_ok(&dest, Some(&f.sha1), Some(f.size), true) } else { dest.exists() };
http::file_ok(&dest, Some(&f.sha1), Some(f.size), true)
} else {
dest.exists()
};
if !ok { if !ok {
downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size))); downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size)));
} }
+1 -5
View File
@@ -170,11 +170,7 @@ impl VersionJson {
} }
pub fn java_component(&self) -> String { pub fn java_component(&self) -> String {
self.java_version self.java_version.as_ref().map(|j| j.component.clone()).filter(|c| !c.is_empty()).unwrap_or_else(|| "jre-legacy".into())
.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. /// True for 1.13+ style argument lists.
+18 -3
View File
@@ -7,11 +7,20 @@ use scopenet_core::{progress, Layout};
use scopenet_shared::Loader; use scopenet_shared::Loader;
async fn run(mc: &str, loader: 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 layout = Layout::new(&root);
let client = scopenet_core::http::client(); let client = scopenet_core::http::client();
let game_dir = layout.instance_dir(&format!("{mc}-{}", loader.as_str())); 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:#}")); let installed = install(&client, &layout, &spec, &progress::noop()).await.unwrap_or_else(|e| panic!("{mc} {loader:?}: {e:#}"));
for path in installed.classpath(&layout) { 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()); assert!(installed.java.exists(), "java missing at {}", installed.java.display());
let opts = LaunchOptions { 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, game_dir,
memory_min_mb: 512, memory_min_mb: 512,
memory_max_mb: 2048, memory_max_mb: 2048,
-1
View File
@@ -3,7 +3,6 @@ name = "scopenet-shared"
description = "Types shared between the ScopeNet launcher and admin panel" description = "Types shared between the ScopeNet launcher and admin panel"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
+36
View File
@@ -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:
+78
View File
@@ -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/<instance>/` | 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`.
+60
View File
@@ -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\<id>\ ← game directory (saves, mods, options.txt, …)
```
## HTTP API
Public (used by launchers). Send `Authorization: Bearer <token>` 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.
+36
View File
@@ -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.
+25
View File
@@ -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 <https://aka.ms/mce-reviewappid> 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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

-1
View File
@@ -3,7 +3,6 @@ name = "scopenet-launcher"
description = "ScopeNet Minecraft launcher" description = "ScopeNet Minecraft launcher"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true
[lib] [lib]
name = "scopenet_launcher_lib" name = "scopenet_launcher_lib"
+2 -4
View File
@@ -45,10 +45,8 @@ impl AccountsFile {
/// Add or replace (same kind + uuid + panel) and make it active. /// Add or replace (same kind + uuid + panel) and make it active.
pub fn upsert(&mut self, account: Account) -> Account { pub fn upsert(&mut self, account: Account) -> Account {
if let Some(existing) = self if let Some(existing) =
.accounts self.accounts.iter_mut().find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
.iter_mut()
.find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
{ {
let id = existing.id.clone(); let id = existing.id.clone();
*existing = Account { id: id.clone(), ..account }; *existing = Account { id: id.clone(), ..account };
+18 -3
View File
@@ -134,7 +134,15 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
let auth = auth.unwrap(); let auth = auth.unwrap();
report(Event::Stage { stage: Stage::Launching, label: "Starting Minecraft".into() }); 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 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 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); 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. // 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::<String>(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
for stream in [child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>), child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>)].into_iter().flatten() { for stream in [
child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>),
child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>),
]
.into_iter()
.flatten()
{
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines(); 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(); 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); emit_state(&app2, &id2, "idle", None);
}); });
Ok(()) Ok(())
-1
View File
@@ -3,7 +3,6 @@ name = "scopenet-panel"
description = "ScopeNet admin panel: manages instances, branding and players for the launcher" description = "ScopeNet admin panel: manages instances, branding and players for the launcher"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true
[dependencies] [dependencies]
scopenet-shared.workspace = true scopenet-shared.workspace = true
+4 -6
View File
@@ -90,12 +90,10 @@ impl UserRow {
} }
pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult<Vec<String>> { pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult<Vec<String>> {
Ok(sqlx::query_scalar( 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")
"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)
.bind(user_id) .await?)
.fetch_all(&state.db)
.await?)
} }
pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUser> { pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUser> {
+1 -4
View File
@@ -89,10 +89,7 @@ pub fn app(state: AppState) -> Router {
.route("/healthz", axum::routing::get(routes::public::health)) .route("/healthz", axum::routing::get(routes::public::health))
.merge(routes::api(&state)) .merge(routes::api(&state))
.nest_service("/files", ServeDir::new(state.cfg.files_dir())) .nest_service("/files", ServeDir::new(state.cfg.files_dir()))
.nest_service( .nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())))
"/uploads",
tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())),
)
.fallback_service(spa) .fallback_service(spa)
.layer(CompressionLayer::new()) .layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
+3 -1
View File
@@ -30,7 +30,9 @@ async fn main() -> anyhow::Result<()> {
} }
async fn shutdown() { 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)] #[cfg(unix)]
let term = async { let term = async {
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
+52 -13
View File
@@ -56,14 +56,38 @@ fn read_json<T: serde::de::DeserializeOwned>(zip: &mut Zip, name: &str) -> Optio
/// Paths we never ship from a plain instance export. /// Paths we never ship from a plain instance export.
fn is_junk(rel: &str) -> bool { fn is_junk(rel: &str) -> bool {
const DIRS: &[&str] = &[ 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}"))) 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. /// 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<Vec<NewFile>> { fn extract_prefix(
zip: &mut Zip,
prefix: &str,
dest_root: &Path,
instance_id: &str,
origin: &'static str,
skip_junk: bool,
) -> AppResult<Vec<NewFile>> {
let mut out = Vec::new(); let mut out = Vec::new();
for i in 0..zip.len() { for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| AppError::bad_request(format!("corrupt zip: {e}")))?; 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() { if safe_join(dest_root, &f.path).is_none() {
continue; 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. // client-overrides win over overrides.
let mut overrides = extract_prefix(zip, "overrides/", dest_root, instance_id, "override", false)?; 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 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 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?; let bytes = download_bytes(state, &url).await?;
Ok((bytes, format!("CurseForge · {} ({})", project.name, info.display_name), project.logo.and_then(|l| l.thumbnail_url))) 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<u8>, fal
let (loader, loader_version) = primary.map(|l| parse_cf_loader(&mc, &l.id)).unwrap_or((Loader::Vanilla, None)); 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?; let mut files = resolve_cf_files(state, &manifest.files).await?;
merge_files(&mut files, overrides); 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"); 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 /// Replace the instance's pack/override files with `info.files`, keep files
/// the admin uploaded by hand, update versions and bump the revision. /// 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?; 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?; sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND origin != 'upload'").bind(instance_id).execute(&mut *tx).await?;
for f in &info.files { 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. /// Delete stored files no longer referenced by the instance.
pub async fn gc_files(state: &AppState, instance_id: &str) -> AppResult<()> { 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 root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id));
let referenced: std::collections::HashSet<String> = store::instance_files(state, instance_id) let referenced: std::collections::HashSet<String> =
.await? store::instance_files(state, instance_id).await?.into_iter().filter(|f| f.url.starts_with("/files/")).map(|f| f.path).collect();
.into_iter()
.filter(|f| f.url.starts_with("/files/"))
.map(|f| f.path)
.collect();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
fn walk(dir: &Path, root: &Path, keep: &std::collections::HashSet<String>) { fn walk(dir: &Path, root: &Path, keep: &std::collections::HashSet<String>) {
let Ok(entries) = std::fs::read_dir(dir) else { return }; let Ok(entries) = std::fs::read_dir(dir) else { return };
+67 -18
View File
@@ -35,12 +35,10 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
.await?; .await?;
let recent: Vec<(Option<String>, Option<String>, String)> = let recent: Vec<(Option<String>, Option<String>, String)> =
sqlx::query_as("SELECT instance_id, username, created_at FROM events ORDER BY id DESC LIMIT 12").fetch_all(&state.db).await?; 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( let players_7d: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?")
"SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?", .bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339())
) .fetch_one(&state.db)
.bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339()) .await?;
.fetch_one(&state.db)
.await?;
// Fill in empty days so the chart is continuous. // Fill in empty days so the chart is continuous.
let mut days = Vec::new(); let mut days = Vec::new();
@@ -81,7 +79,8 @@ async fn view(state: &AppState, user: UserRow) -> AppResult<AdminUserView> {
} }
pub async fn list_users(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminUserView>>> { pub async fn list_users(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminUserView>>> {
let users: Vec<UserRow> = sqlx::query_as("SELECT * FROM users ORDER BY status = 'pending' DESC, username COLLATE NOCASE").fetch_all(&state.db).await?; let users: Vec<UserRow> =
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()); let mut out = Vec::with_capacity(users.len());
for u in users { for u in users {
out.push(view(&state, u).await?); out.push(view(&state, u).await?);
@@ -158,7 +157,12 @@ pub async fn create_user(_: AdminUser, State(state): State<AppState>, Json(input
Ok(Json(view(&state, user).await?)) Ok(Json(view(&state, user).await?))
} }
pub async fn update_user(AdminUser(me): AdminUser, State(state): State<AppState>, Path(id): Path<i64>, Json(input): Json<UserInput>) -> AppResult<Json<AdminUserView>> { pub async fn update_user(
AdminUser(me): AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<UserInput>,
) -> AppResult<Json<AdminUserView>> {
let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?") let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(id) .bind(id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -166,10 +170,18 @@ pub async fn update_user(AdminUser(me): AdminUser, State(state): State<AppState>
.ok_or_else(|| AppError::not_found("user not found"))?; .ok_or_else(|| AppError::not_found("user not found"))?;
if let Some(password) = input.password.filter(|p| !p.is_empty()) { if let Some(password) = input.password.filter(|p| !p.is_empty()) {
auth::validate_password(&password)?; 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 { 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 { if let Some(role) = input.role {
check_role(&role)?; check_role(&role)?;
@@ -358,7 +370,11 @@ async fn validated(state: &AppState, mut input: InstanceInput) -> AppResult<Inst
Ok(input) Ok(input)
} }
pub async fn create_instance(_: AdminUser, State(state): State<AppState>, Json(input): Json<InstanceInput>) -> AppResult<Json<AdminInstance>> { pub async fn create_instance(
_: AdminUser,
State(state): State<AppState>,
Json(input): Json<InstanceInput>,
) -> AppResult<Json<AdminInstance>> {
let input = validated(&state, input).await?; let input = validated(&state, input).await?;
let id = store::unique_slug(&state, &input.name).await?; let id = store::unique_slug(&state, &input.name).await?;
let now = crate::db::now(); let now = crate::db::now();
@@ -409,7 +425,12 @@ async fn detail(state: &AppState, id: String) -> AppResult<Json<InstanceDetail>>
Ok(Json(InstanceDetail { instance: row.to_admin(stats), files: store::instance_files(state, &id).await? })) Ok(Json(InstanceDetail { instance: row.to_admin(stats), files: store::instance_files(state, &id).await? }))
} }
pub async fn update_instance(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<InstanceInput>) -> AppResult<Json<InstanceDetail>> { pub async fn update_instance(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<InstanceInput>,
) -> AppResult<Json<InstanceDetail>> {
let existing = store::get_instance(&state, &id).await?; let existing = store::get_instance(&state, &id).await?;
let input = validated(&state, input).await?; let input = validated(&state, input).await?;
let mem = input.memory.unwrap_or_default(); let mem = input.memory.unwrap_or_default();
@@ -473,7 +494,12 @@ pub struct ModrinthImport {
project_id: Option<String>, project_id: Option<String>,
} }
pub async fn import_modrinth(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Json(req): Json<ModrinthImport>) -> AppResult<Json<InstanceDetail>> { pub async fn import_modrinth(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<ModrinthImport>,
) -> AppResult<Json<InstanceDetail>> {
let row = store::get_instance(&state, &id).await?; let row = store::get_instance(&state, &id).await?;
let (bytes, label, icon) = packs::fetch_modrinth(&state, &req.version_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?; let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?;
@@ -488,7 +514,12 @@ pub struct CurseForgeImport {
file_id: i64, file_id: i64,
} }
pub async fn import_curseforge(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Json(req): Json<CurseForgeImport>) -> AppResult<Json<InstanceDetail>> { pub async fn import_curseforge(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<CurseForgeImport>,
) -> AppResult<Json<InstanceDetail>> {
let row = store::get_instance(&state, &id).await?; let row = store::get_instance(&state, &id).await?;
let (bytes, label, icon) = packs::fetch_curseforge(&state, req.mod_id, req.file_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?; 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. /// Upload a .mrpack / CurseForge zip / plain instance zip.
pub async fn import_upload(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, mut form: Multipart) -> AppResult<Json<InstanceDetail>> { pub async fn import_upload(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<String>,
mut form: Multipart,
) -> AppResult<Json<InstanceDetail>> {
store::get_instance(&state, &id).await?; store::get_instance(&state, &id).await?;
let mut bytes = None; let mut bytes = None;
let mut filename = String::from("upload.zip"); let mut filename = String::from("upload.zip");
@@ -528,7 +564,10 @@ pub async fn import_upload(_: AdminUser, State(state): State<AppState>, Path(id)
let (mut info, kind) = packs::import_zip(&state, &id, bytes, fallback).await?; let (mut info, kind) = packs::import_zip(&state, &id, bytes, fallback).await?;
if kind == "zip" && info.loader != Loader::Vanilla { if kind == "zip" && info.loader != Loader::Vanilla {
info.loader_version = 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 { let label = match kind {
"modrinth" | "curseforge" if !info.name.is_empty() => format!("{} {}", info.name, info.version).trim().to_string(), "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<AppState>, Path(id)
/// Add individual files (extra mods, configs, or a CurseForge mod that /// Add individual files (extra mods, configs, or a CurseForge mod that
/// can't be downloaded automatically). /// can't be downloaded automatically).
pub async fn upload_files(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, mut form: Multipart) -> AppResult<Json<InstanceDetail>> { pub async fn upload_files(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<String>,
mut form: Multipart,
) -> AppResult<Json<InstanceDetail>> {
store::get_instance(&state, &id).await?; store::get_instance(&state, &id).await?;
let root = state.cfg.files_dir().join(sanitize_id(&id)); let root = state.cfg.files_dir().join(sanitize_id(&id));
let mut folder = String::from("mods"); let mut folder = String::from("mods");
@@ -591,7 +635,12 @@ pub struct PathQuery {
path: String, path: String,
} }
pub async fn delete_file(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Query(q): Query<PathQuery>) -> AppResult<Json<InstanceDetail>> { pub async fn delete_file(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<PathQuery>,
) -> AppResult<Json<InstanceDetail>> {
sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND path = ?").bind(&id).bind(&q.path).execute(&state.db).await?; 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?; store::bump_revision(&state, &id).await?;
packs::gc_files(&state, &id).await?; packs::gc_files(&state, &id).await?;
+23 -13
View File
@@ -20,11 +20,7 @@ pub struct McVersion {
pub async fn minecraft(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<serde_json::Value>> { pub async fn minecraft(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<serde_json::Value>> {
let m = meta::mojang_manifest(&state.http).await?; let m = meta::mojang_manifest(&state.http).await?;
let versions: Vec<McVersion> = m let versions: Vec<McVersion> = m.versions.into_iter().map(|v| McVersion { id: v.id, kind: v.kind, released: v.release_time }).collect();
.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 }))) Ok(Json(serde_json::json!({ "latest": m.latest, "versions": versions })))
} }
@@ -33,7 +29,12 @@ pub struct LoaderQuery {
mc: String, mc: String,
} }
pub async fn loaders(_: AdminUser, State(state): State<AppState>, Path(loader): Path<String>, Query(q): Query<LoaderQuery>) -> AppResult<Json<Vec<LoaderVersion>>> { pub async fn loaders(
_: AdminUser,
State(state): State<AppState>,
Path(loader): Path<String>,
Query(q): Query<LoaderQuery>,
) -> AppResult<Json<Vec<LoaderVersion>>> {
let loader = Loader::parse(&loader).unwrap_or_default(); let loader = Loader::parse(&loader).unwrap_or_default();
Ok(Json(meta::loader_versions(&state.http, loader, &q.mc).await?)) Ok(Json(meta::loader_versions(&state.http, loader, &q.mc).await?))
} }
@@ -44,7 +45,11 @@ pub struct SearchQuery {
q: String, q: String,
} }
pub async fn modrinth_search(_: AdminUser, State(state): State<AppState>, Query(q): Query<SearchQuery>) -> AppResult<Json<serde_json::Value>> { pub async fn modrinth_search(
_: AdminUser,
State(state): State<AppState>,
Query(q): Query<SearchQuery>,
) -> AppResult<Json<serde_json::Value>> {
let resp = state let resp = state
.http .http
.get("https://api.modrinth.com/v2/search") .get("https://api.modrinth.com/v2/search")
@@ -57,16 +62,21 @@ pub async fn modrinth_search(_: AdminUser, State(state): State<AppState>, Query(
Ok(Json(resp.json().await.map_err(anyhow::Error::from)?)) Ok(Json(resp.json().await.map_err(anyhow::Error::from)?))
} }
pub async fn modrinth_versions(_: AdminUser, State(state): State<AppState>, Path(project): Path<String>) -> AppResult<Json<Vec<packs::MrVersion>>> { pub async fn modrinth_versions(
_: AdminUser,
State(state): State<AppState>,
Path(project): Path<String>,
) -> AppResult<Json<Vec<packs::MrVersion>>> {
let url = format!("https://api.modrinth.com/v2/project/{}/version", urlencode(&project)); let url = format!("https://api.modrinth.com/v2/project/{}/version", urlencode(&project));
Ok(Json(scopenet_core::http::get_json(&state.http, &url).await?)) Ok(Json(scopenet_core::http::get_json(&state.http, &url).await?))
} }
pub async fn curseforge_search(_: AdminUser, State(state): State<AppState>, Query(q): Query<SearchQuery>) -> AppResult<Json<serde_json::Value>> { pub async fn curseforge_search(
let path = format!( _: AdminUser,
"/mods/search?gameId=432&classId=4471&pageSize=24&sortField=2&sortOrder=desc&searchFilter={}", State(state): State<AppState>,
urlencode(&q.q) Query(q): Query<SearchQuery>,
); ) -> AppResult<Json<serde_json::Value>> {
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?)) Ok(Json(packs::cf_raw_get(&state, &path).await?))
} }
+10 -2
View File
@@ -43,7 +43,11 @@ pub async fn manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser)
})) }))
} }
pub async fn instance_manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser, Path(id): Path<String>) -> AppResult<Json<InstanceManifest>> { pub async fn instance_manifest(
State(state): State<AppState>,
MaybeUser(user): MaybeUser,
Path(id): Path<String>,
) -> AppResult<Json<InstanceManifest>> {
let row = store::get_instance(&state, &id).await?; let row = store::get_instance(&state, &id).await?;
let groups = match &user { let groups = match &user {
Some(u) => auth::user_groups(&state, u.id).await?, Some(u) => auth::user_groups(&state, u.id).await?,
@@ -120,7 +124,11 @@ pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppR
Ok(Json(auth::public_user(&state, &user).await?)) Ok(Json(auth::public_user(&state, &user).await?))
} }
pub async fn event(State(state): State<AppState>, MaybeUser(user): MaybeUser, Json(ev): Json<LaunchEvent>) -> AppResult<Json<serde_json::Value>> { pub async fn event(
State(state): State<AppState>,
MaybeUser(user): MaybeUser,
Json(ev): Json<LaunchEvent>,
) -> AppResult<Json<serde_json::Value>> {
let kind = if ev.kind == "launch" { "launch" } else { "other" }; 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::<String>()); let name = user.map(|u| u.username).or(ev.username).map(|n| n.chars().take(32).collect::<String>());
sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)") sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)")
+5 -6
View File
@@ -193,12 +193,11 @@ pub struct FileStats {
} }
pub async fn file_stats(state: &AppState, instance_id: &str) -> AppResult<FileStats> { pub async fn file_stats(state: &AppState, instance_id: &str) -> AppResult<FileStats> {
let (count, size, missing): (i64, Option<i64>, Option<i64>) = sqlx::query_as( let (count, size, missing): (i64, Option<i64>, Option<i64>) =
"SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?", 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)
.bind(instance_id) .fetch_one(&state.db)
.fetch_one(&state.db) .await?;
.await?;
Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 }) Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 })
} }
+32 -9
View File
@@ -63,7 +63,12 @@ fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec<u8>)
for (k, v) in fields { 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=\"{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); body.extend_from_slice(file.1);
write!(body, "\r\n--{boundary}--\r\n").unwrap(); write!(body, "\r\n--{boundary}--\r\n").unwrap();
(format!("multipart/form-data; boundary={boundary}"), body) (format!("multipart/form-data; boundary={boundary}"), body)
@@ -105,9 +110,7 @@ async fn admin_only_routes_are_guarded() {
assert_eq!(s, StatusCode::UNAUTHORIZED); assert_eq!(s, StatusCode::UNAUTHORIZED);
let admin = t.login("admin", "supersecret").await; let admin = t.login("admin", "supersecret").await;
let (s, v) = t let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await;
.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"})))
.await;
assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve")); 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. // 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 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 (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; let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}"); assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
let (ct, body) = multipart(&[("mc_version", "1.20.1"), ("loader", "vanilla")], ("pack.zip", &zip)); 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; let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(s, StatusCode::OK, "{v}");
let paths: Vec<&str> = v["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect(); 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}"), ("client-overrides/config/a.json", b"{\"client\":1}"),
]); ]);
let (ct, body) = multipart(&[], ("fab.mrpack", &zip)); 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; let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["instance"]["mc_version"], "1.21.1"); 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"); assert_eq!(m["branding"]["colors"]["accent"], "#ff5500");
let (ct, body) = multipart(&[], ("evil.svg", b"<svg onload=alert(1)>")); let (ct, body) = multipart(&[], ("evil.svg", b"<svg onload=alert(1)>"));
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; let (s, _) = t.send(req).await;
assert_eq!(s, StatusCode::BAD_REQUEST); assert_eq!(s, StatusCode::BAD_REQUEST);
let (ct, body) = multipart(&[], ("logo.png", b"\x89PNG")); 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; let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
assert!(v["url"].as_str().unwrap().starts_with("/uploads/")); assert!(v["url"].as_str().unwrap().starts_with("/uploads/"));
+2
View File
@@ -0,0 +1,2 @@
max_width = 140
use_small_heuristics = "Max"