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
No files matched your search
@@ -0,0 +1,10 @@
|
||||
target
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.github
|
||||
data
|
||||
docs
|
||||
launcher/src
|
||||
launcher/src-tauri/icons
|
||||
launcher/src-tauri/gen
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -5,6 +5,7 @@ build/
|
||||
.svelte-kit/
|
||||
*.log
|
||||
.env
|
||||
!.env.example
|
||||
/data
|
||||
/panel/server/data
|
||||
launcher/src-tauri/gen/schemas
|
||||
|
||||
@@ -5,7 +5,6 @@ members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/scopeddlol/SCOPENET-MC"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -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"]
|
||||
@@ -1,2 +1,121 @@
|
||||
# SCOPENET-MC
|
||||
A Windows/Linux Minecraft Launcher with a Docker-hosted admin panel.
|
||||
<div align="center">
|
||||
|
||||
<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.
|
||||
@@ -3,7 +3,6 @@ name = "scopenet-core"
|
||||
description = "Minecraft install / launch engine used by the ScopeNet launcher (and meta lookups in the panel)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
scopenet-shared.workspace = true
|
||||
|
||||
@@ -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> {
|
||||
let path = layout.assets().join("indexes").join(format!("{}.json", index.id));
|
||||
if !http::file_ok(&path, index.sha1.as_deref(), index.size, true) {
|
||||
http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {})
|
||||
.await?;
|
||||
http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {}).await?;
|
||||
}
|
||||
Ok(serde_json::from_slice(&std::fs::read(&path)?)?)
|
||||
}
|
||||
@@ -64,11 +63,7 @@ pub fn materialize_legacy(layout: &Layout, index_id: &str, index: &AssetIndex, g
|
||||
if !index.is_virtual && !index.map_to_resources {
|
||||
return Ok(None);
|
||||
}
|
||||
let target = if index.map_to_resources {
|
||||
game_dir.join("resources")
|
||||
} else {
|
||||
layout.assets().join("virtual").join(index_id)
|
||||
};
|
||||
let target = if index.map_to_resources { game_dir.join("resources") } else { layout.assets().join("virtual").join(index_id) };
|
||||
for (name, obj) in &index.objects {
|
||||
let Some(dest) = crate::paths::safe_join(&target, name) else { continue };
|
||||
if dest.exists() {
|
||||
|
||||
@@ -129,10 +129,8 @@ async fn try_download(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn F
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
}
|
||||
let resp = client.get(&dl.url).send().await?.error_for_status()?;
|
||||
let part = dl.dest.with_extension(format!(
|
||||
"{}part",
|
||||
dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()
|
||||
));
|
||||
let part =
|
||||
dl.dest.with_extension(format!("{}part", dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()));
|
||||
let mut file = tokio::fs::File::create(&part).await?;
|
||||
let mut hasher = Sha1::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
|
||||
@@ -47,13 +47,8 @@ impl Installed {
|
||||
pub fn classpath(&self, layout: &Layout) -> Vec<PathBuf> {
|
||||
let libs_dir = layout.libraries();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut cp: Vec<PathBuf> = self
|
||||
.libraries
|
||||
.iter()
|
||||
.filter(|l| l.classpath)
|
||||
.map(|l| l.file(&libs_dir))
|
||||
.filter(|p| seen.insert(p.clone()))
|
||||
.collect();
|
||||
let mut cp: Vec<PathBuf> =
|
||||
self.libraries.iter().filter(|l| l.classpath).map(|l| l.file(&libs_dir)).filter(|p| seen.insert(p.clone())).collect();
|
||||
cp.push(self.client_jar.clone());
|
||||
cp
|
||||
}
|
||||
@@ -69,11 +64,8 @@ async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Re
|
||||
return Ok(serde_json::from_slice(&bytes)?);
|
||||
}
|
||||
};
|
||||
let entry = manifest
|
||||
.versions
|
||||
.iter()
|
||||
.find(|v| v.id == mc)
|
||||
.ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?;
|
||||
let entry =
|
||||
manifest.versions.iter().find(|v| v.id == mc).ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?;
|
||||
if !http::file_ok(&path, entry.sha1.as_deref(), None, true) {
|
||||
http::download_one(client, &Download::new(entry.url.clone(), path.clone(), entry.sha1.clone(), None), &|_| {}).await?;
|
||||
}
|
||||
@@ -82,11 +74,8 @@ async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Re
|
||||
|
||||
fn library_downloads(libs: &[ResolvedLib], layout: &Layout, deep: bool) -> Vec<Download> {
|
||||
let dir = layout.libraries();
|
||||
let mut out: Vec<Download> = libs
|
||||
.iter()
|
||||
.filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep))
|
||||
.filter_map(|l| l.download(&dir))
|
||||
.collect();
|
||||
let mut out: Vec<Download> =
|
||||
libs.iter().filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep)).filter_map(|l| l.download(&dir)).collect();
|
||||
out.sort_by(|a, b| a.dest.cmp(&b.dest));
|
||||
out.dedup_by(|a, b| a.dest == b.dest);
|
||||
out
|
||||
@@ -146,7 +135,11 @@ pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSp
|
||||
Loader::Vanilla => None,
|
||||
Loader::Fabric | Loader::Quilt => {
|
||||
let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap();
|
||||
progress::stage(reporter, Stage::Loader, format!("Installing {} {lv}", if spec.loader == Loader::Fabric { "Fabric" } else { "Quilt" }));
|
||||
progress::stage(
|
||||
reporter,
|
||||
Stage::Loader,
|
||||
format!("Installing {} {lv}", if spec.loader == Loader::Fabric { "Fabric" } else { "Quilt" }),
|
||||
);
|
||||
Some(loaders::fabric::profile(client, layout, spec.loader, mc, &lv).await?)
|
||||
}
|
||||
Loader::Forge | Loader::NeoForge => {
|
||||
@@ -204,8 +197,8 @@ pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSp
|
||||
};
|
||||
|
||||
let assets_root = layout.assets();
|
||||
let game_assets = assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)?
|
||||
.unwrap_or_else(|| assets_root.clone());
|
||||
let game_assets =
|
||||
assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)?.unwrap_or_else(|| assets_root.clone());
|
||||
|
||||
Ok(Installed {
|
||||
java: java_windowed,
|
||||
|
||||
@@ -86,7 +86,11 @@ pub fn sibling_exe(java: &Path, windowed: bool) -> PathBuf {
|
||||
}
|
||||
let name = if windowed { "javaw.exe" } else { "java.exe" };
|
||||
let candidate = java.with_file_name(name);
|
||||
if candidate.exists() { candidate } else { java.to_path_buf() }
|
||||
if candidate.exists() {
|
||||
candidate
|
||||
} else {
|
||||
java.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure the Mojang runtime `component` (e.g. `java-runtime-delta`) is
|
||||
@@ -102,15 +106,14 @@ pub async fn ensure_runtime(
|
||||
let marker = dir.join(".scopenet-runtime");
|
||||
let installed = std::fs::read_to_string(&marker).ok();
|
||||
|
||||
let index: HashMap<String, HashMap<String, Vec<RuntimeEntry>>> =
|
||||
match http::get_json(client, RUNTIME_INDEX).await {
|
||||
Ok(i) => i,
|
||||
Err(e) if installed.is_some() && java_exe(&dir, false).exists() => {
|
||||
tracing::warn!("java index unreachable ({e}); using installed {component}");
|
||||
return Ok(dir);
|
||||
}
|
||||
Err(e) => return Err(e).context("fetching Java runtime index"),
|
||||
};
|
||||
let index: HashMap<String, HashMap<String, Vec<RuntimeEntry>>> = match http::get_json(client, RUNTIME_INDEX).await {
|
||||
Ok(i) => i,
|
||||
Err(e) if installed.is_some() && java_exe(&dir, false).exists() => {
|
||||
tracing::warn!("java index unreachable ({e}); using installed {component}");
|
||||
return Ok(dir);
|
||||
}
|
||||
Err(e) => return Err(e).context("fetching Java runtime index"),
|
||||
};
|
||||
let entry = index
|
||||
.get(platform_key())
|
||||
.and_then(|p| p.get(component))
|
||||
@@ -188,7 +191,11 @@ pub fn parse_java_version(text: &str) -> Option<u32> {
|
||||
let ver = &rest[..rest.find('"')?];
|
||||
let mut parts = ver.split(['.', '_', '-', '+']);
|
||||
let first: u32 = parts.next()?.parse().ok()?;
|
||||
if first == 1 { parts.next()?.parse().ok() } else { Some(first) }
|
||||
if first == 1 {
|
||||
parts.next()?.parse().ok()
|
||||
} else {
|
||||
Some(first)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -167,12 +167,7 @@ pub struct Command {
|
||||
pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Command {
|
||||
let v = &installed.version;
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let classpath = installed
|
||||
.classpath(layout)
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(sep);
|
||||
let classpath = installed.classpath(layout).iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join(sep);
|
||||
|
||||
let quick_play = v.supports_quick_play();
|
||||
let env = Env::current()
|
||||
@@ -259,11 +254,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
|
||||
/// Java 9+ can read arguments from a file, which sidesteps Windows'
|
||||
/// 32k command-line limit on huge modpacks.
|
||||
fn write_argfile(path: &Path, args: &[String]) -> Result<()> {
|
||||
let body = args
|
||||
.iter()
|
||||
.map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let body = args.iter().map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\""))).collect::<Vec<_>>().join("\n");
|
||||
std::fs::write(path, body)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -340,7 +331,13 @@ mod tests {
|
||||
|
||||
fn opts(join: bool) -> LaunchOptions {
|
||||
LaunchOptions {
|
||||
auth: Auth { username: "Steve".into(), uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), access_token: "0".into(), user_type: "legacy".into(), xuid: None },
|
||||
auth: Auth {
|
||||
username: "Steve".into(),
|
||||
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
|
||||
access_token: "0".into(),
|
||||
user_type: "legacy".into(),
|
||||
xuid: None,
|
||||
},
|
||||
game_dir: "/g".into(),
|
||||
memory_min_mb: 1024,
|
||||
memory_max_mb: 4096,
|
||||
|
||||
@@ -37,7 +37,11 @@ impl ResolvedLib {
|
||||
}
|
||||
|
||||
fn join_url(base: &str, path: &str) -> String {
|
||||
if base.ends_with('/') { format!("{base}{path}") } else { format!("{base}/{path}") }
|
||||
if base.ends_with('/') {
|
||||
format!("{base}{path}")
|
||||
} else {
|
||||
format!("{base}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
|
||||
@@ -56,11 +60,7 @@ pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
|
||||
if let Some(natives) = &lib.natives {
|
||||
if let Some(classifier) = natives.get(env.os) {
|
||||
let classifier = classifier.replace("${arch}", env.bits());
|
||||
let from_downloads = lib
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|d| d.classifiers.as_ref())
|
||||
.and_then(|c| c.get(&classifier));
|
||||
let from_downloads = lib.downloads.as_ref().and_then(|d| d.classifiers.as_ref()).and_then(|c| c.get(&classifier));
|
||||
let native_coord = coord.with_classifier(&classifier);
|
||||
let (path, url, sha1, size) = match from_downloads {
|
||||
Some(a) => (
|
||||
@@ -207,10 +207,7 @@ mod tests {
|
||||
assert_eq!(libs.len(), 3);
|
||||
assert!(libs[0].native && !libs[0].classpath);
|
||||
assert_eq!(libs[0].path, "p/w64.jar");
|
||||
assert_eq!(
|
||||
libs[1].url.as_deref(),
|
||||
Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar")
|
||||
);
|
||||
assert_eq!(libs[1].url.as_deref(), Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar"));
|
||||
assert!(libs[2].url.is_none(), "empty url = bundled in installer");
|
||||
}
|
||||
}
|
||||
@@ -253,11 +253,8 @@ async fn run_processors(ctx: &ForgeInstall<'_>, zip: &mut Zip, profile: &Install
|
||||
data.insert("LIBRARY_DIR".into(), libs_dir.to_string_lossy().into_owned());
|
||||
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let processors: Vec<&Processor> = profile
|
||||
.processors
|
||||
.iter()
|
||||
.filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true))
|
||||
.collect();
|
||||
let processors: Vec<&Processor> =
|
||||
profile.processors.iter().filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true)).collect();
|
||||
let total = processors.len() as u32;
|
||||
|
||||
for (i, proc) in processors.into_iter().enumerate() {
|
||||
|
||||
@@ -106,11 +106,9 @@ pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str)
|
||||
.collect()
|
||||
}
|
||||
Loader::Forge => {
|
||||
let all: HashMap<String, Vec<String>> =
|
||||
get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?;
|
||||
let promos: ForgePromos = get_json(client, &format!("{FORGE_FILES}/promotions_slim.json"))
|
||||
.await
|
||||
.unwrap_or(ForgePromos { promos: HashMap::new() });
|
||||
let all: HashMap<String, Vec<String>> = get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?;
|
||||
let promos: ForgePromos =
|
||||
get_json(client, &format!("{FORGE_FILES}/promotions_slim.json")).await.unwrap_or(ForgePromos { promos: HashMap::new() });
|
||||
let recommended = promos.promos.get(&format!("{mc}-recommended")).map(|v| format!("{mc}-{v}"));
|
||||
let mut list: Vec<LoaderVersion> = all
|
||||
.get(mc)
|
||||
@@ -137,12 +135,7 @@ pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str)
|
||||
}
|
||||
|
||||
/// Turn "latest"/"recommended"/empty into a concrete loader version.
|
||||
pub async fn resolve_loader_version(
|
||||
client: &reqwest::Client,
|
||||
loader: Loader,
|
||||
mc: &str,
|
||||
requested: Option<&str>,
|
||||
) -> Result<Option<String>> {
|
||||
pub async fn resolve_loader_version(client: &reqwest::Client, loader: Loader, mc: &str, requested: Option<&str>) -> Result<Option<String>> {
|
||||
if loader == Loader::Vanilla {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -64,11 +64,7 @@ pub struct McSkin {
|
||||
}
|
||||
|
||||
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
|
||||
let resp = client
|
||||
.post(DEVICE_CODE_URL)
|
||||
.form(&[("client_id", client_id), ("scope", SCOPE)])
|
||||
.send()
|
||||
.await?;
|
||||
let resp = client.post(DEVICE_CODE_URL).form(&[("client_id", client_id), ("scope", SCOPE)]).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("Microsoft rejected the sign-in request: {body}");
|
||||
@@ -116,12 +112,7 @@ pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code:
|
||||
pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result<MsaSession> {
|
||||
let resp: TokenResponse = client
|
||||
.post(TOKEN_URL)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("client_id", client_id),
|
||||
("refresh_token", refresh_token),
|
||||
("scope", SCOPE),
|
||||
])
|
||||
.form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
@@ -210,22 +201,12 @@ async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: Stri
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://api.minecraftservices.com/minecraft/profile")
|
||||
.bearer_auth(&mc.access_token)
|
||||
.send()
|
||||
.await?;
|
||||
let resp = client.get("https://api.minecraftservices.com/minecraft/profile").bearer_auth(&mc.access_token).send().await?;
|
||||
if resp.status().as_u16() == 404 {
|
||||
bail!("this Microsoft account doesn't own Minecraft: Java Edition");
|
||||
}
|
||||
let profile: McProfile = resp.error_for_status()?.json().await?;
|
||||
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
Ok(MsaSession {
|
||||
refresh_token,
|
||||
mc_access_token: mc.access_token,
|
||||
mc_expires_at: now + mc.expires_in,
|
||||
profile,
|
||||
xuid,
|
||||
})
|
||||
Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid })
|
||||
}
|
||||
@@ -49,12 +49,13 @@ impl Layout {
|
||||
|
||||
/// Keep instance ids filesystem-safe no matter what the panel sends.
|
||||
pub fn sanitize_id(id: &str) -> String {
|
||||
let s: String = id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
||||
.collect();
|
||||
let s: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect();
|
||||
let s = s.trim_matches('.').to_string();
|
||||
if s.is_empty() { "_".into() } else { s }
|
||||
if s.is_empty() {
|
||||
"_".into()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a server-provided relative path onto `base`, refusing anything that
|
||||
@@ -72,7 +73,11 @@ pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
|
||||
p => out.push(p),
|
||||
}
|
||||
}
|
||||
if out == base { None } else { Some(out) }
|
||||
if out == base {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -231,7 +231,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn flattens_motd() {
|
||||
let v: serde_json::Value = serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap();
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap();
|
||||
assert_eq!(flatten_chat(&v), "§b§lScope§rNet");
|
||||
assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello");
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ impl Env {
|
||||
|
||||
/// `${arch}` substitution used in legacy native classifiers.
|
||||
pub fn bits(&self) -> &'static str {
|
||||
if self.arch == "x86" { "32" } else { "64" }
|
||||
if self.arch == "x86" {
|
||||
"32"
|
||||
} else {
|
||||
"64"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +130,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn evaluates_rules() {
|
||||
let rules: Vec<Rule> = serde_json::from_str(
|
||||
r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rules: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#).unwrap();
|
||||
assert!(allowed(&rules, &env("windows")));
|
||||
assert!(!allowed(&rules, &env("osx")));
|
||||
|
||||
|
||||
@@ -46,7 +46,11 @@ pub fn upsert(game_dir: &Path, name: &str, address: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn format_address(host: &str, port: u16) -> String {
|
||||
if port == 25565 { host.to_string() } else { format!("{host}:{port}") }
|
||||
if port == 25565 {
|
||||
host.to_string()
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -74,10 +78,7 @@ mod tests {
|
||||
upsert(dir.path(), "Friend", "friend.net").unwrap();
|
||||
upsert(dir.path(), "ScopeNet", "play.scopenet.gg").unwrap();
|
||||
upsert(dir.path(), "ScopeNet SMP", "play.scopenet.gg").unwrap();
|
||||
assert_eq!(
|
||||
names(dir.path()),
|
||||
vec![("ScopeNet SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())]
|
||||
);
|
||||
assert_eq!(names(dir.path()), vec![("ScopeNet SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())]);
|
||||
assert_eq!(format_address("a.b", 25565), "a.b");
|
||||
assert_eq!(format_address("a.b", 25570), "a.b:25570");
|
||||
}
|
||||
|
||||
@@ -76,11 +76,7 @@ pub async fn sync(
|
||||
continue;
|
||||
};
|
||||
wanted.insert(f.path.replace('\\', "/"));
|
||||
let ok = if changed {
|
||||
http::file_ok(&dest, Some(&f.sha1), Some(f.size), true)
|
||||
} else {
|
||||
dest.exists()
|
||||
};
|
||||
let ok = if changed { http::file_ok(&dest, Some(&f.sha1), Some(f.size), true) } else { dest.exists() };
|
||||
if !ok {
|
||||
downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size)));
|
||||
}
|
||||
|
||||
@@ -170,11 +170,7 @@ impl VersionJson {
|
||||
}
|
||||
|
||||
pub fn java_component(&self) -> String {
|
||||
self.java_version
|
||||
.as_ref()
|
||||
.map(|j| j.component.clone())
|
||||
.filter(|c| !c.is_empty())
|
||||
.unwrap_or_else(|| "jre-legacy".into())
|
||||
self.java_version.as_ref().map(|j| j.component.clone()).filter(|c| !c.is_empty()).unwrap_or_else(|| "jre-legacy".into())
|
||||
}
|
||||
|
||||
/// True for 1.13+ style argument lists.
|
||||
|
||||
@@ -7,11 +7,20 @@ use scopenet_core::{progress, Layout};
|
||||
use scopenet_shared::Loader;
|
||||
|
||||
async fn run(mc: &str, loader: Loader) {
|
||||
let root = std::env::var("SCOPENET_TEST_ROOT").map(std::path::PathBuf::from).unwrap_or_else(|_| std::env::temp_dir().join("scopenet-online"));
|
||||
let root =
|
||||
std::env::var("SCOPENET_TEST_ROOT").map(std::path::PathBuf::from).unwrap_or_else(|_| std::env::temp_dir().join("scopenet-online"));
|
||||
let layout = Layout::new(&root);
|
||||
let client = scopenet_core::http::client();
|
||||
let game_dir = layout.instance_dir(&format!("{mc}-{}", loader.as_str()));
|
||||
let spec = InstallSpec { mc_version: mc.into(), loader, loader_version: None, java_override: None, game_dir: game_dir.clone(), concurrency: 16, deep_verify: false };
|
||||
let spec = InstallSpec {
|
||||
mc_version: mc.into(),
|
||||
loader,
|
||||
loader_version: None,
|
||||
java_override: None,
|
||||
game_dir: game_dir.clone(),
|
||||
concurrency: 16,
|
||||
deep_verify: false,
|
||||
};
|
||||
let installed = install(&client, &layout, &spec, &progress::noop()).await.unwrap_or_else(|e| panic!("{mc} {loader:?}: {e:#}"));
|
||||
|
||||
for path in installed.classpath(&layout) {
|
||||
@@ -20,7 +29,13 @@ async fn run(mc: &str, loader: Loader) {
|
||||
assert!(installed.java.exists(), "java missing at {}", installed.java.display());
|
||||
|
||||
let opts = LaunchOptions {
|
||||
auth: Auth { username: "CiBot".into(), uuid: scopenet_shared::offline_uuid("CiBot"), access_token: "0".into(), user_type: "legacy".into(), xuid: None },
|
||||
auth: Auth {
|
||||
username: "CiBot".into(),
|
||||
uuid: scopenet_shared::offline_uuid("CiBot"),
|
||||
access_token: "0".into(),
|
||||
user_type: "legacy".into(),
|
||||
xuid: None,
|
||||
},
|
||||
game_dir,
|
||||
memory_min_mb: 512,
|
||||
memory_max_mb: 2048,
|
||||
|
||||
@@ -3,7 +3,6 @@ name = "scopenet-shared"
|
||||
description = "Types shared between the ScopeNet launcher and admin panel"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
|
||||
@@ -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:
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -3,7 +3,6 @@ name = "scopenet-launcher"
|
||||
description = "ScopeNet Minecraft launcher"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "scopenet_launcher_lib"
|
||||
|
||||
@@ -45,10 +45,8 @@ impl AccountsFile {
|
||||
|
||||
/// Add or replace (same kind + uuid + panel) and make it active.
|
||||
pub fn upsert(&mut self, account: Account) -> Account {
|
||||
if let Some(existing) = self
|
||||
.accounts
|
||||
.iter_mut()
|
||||
.find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
|
||||
if let Some(existing) =
|
||||
self.accounts.iter_mut().find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
|
||||
{
|
||||
let id = existing.id.clone();
|
||||
*existing = Account { id: id.clone(), ..account };
|
||||
|
||||
@@ -134,7 +134,15 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
let auth = auth.unwrap();
|
||||
|
||||
report(Event::Stage { stage: Stage::Launching, label: "Starting Minecraft".into() });
|
||||
let pick = |a: u32, b: u32, c: u32| if a > 0 { a } else if b > 0 { b } else { c };
|
||||
let pick = |a: u32, b: u32, c: u32| {
|
||||
if a > 0 {
|
||||
a
|
||||
} else if b > 0 {
|
||||
b
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
let max = pick(ov.memory_max_mb, settings.memory_max_mb, inst.memory.max_mb).max(512);
|
||||
let min = pick(ov.memory_min_mb, settings.memory_min_mb, inst.memory.min_mb).min(max);
|
||||
let mut jvm = launch::split_args(&inst.jvm_args);
|
||||
@@ -200,7 +208,13 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
|
||||
// Stream logs to the UI in small batches (cheap IPC), keep a tail for crash reports.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<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();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
@@ -268,7 +282,8 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
w.set_focus().ok();
|
||||
}
|
||||
}
|
||||
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report }).ok();
|
||||
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report })
|
||||
.ok();
|
||||
emit_state(&app2, &id2, "idle", None);
|
||||
});
|
||||
Ok(())
|
||||
|
||||
@@ -3,7 +3,6 @@ name = "scopenet-panel"
|
||||
description = "ScopeNet admin panel: manages instances, branding and players for the launcher"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
scopenet-shared.workspace = true
|
||||
|
||||
@@ -90,12 +90,10 @@ impl UserRow {
|
||||
}
|
||||
|
||||
pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult<Vec<String>> {
|
||||
Ok(sqlx::query_scalar(
|
||||
"SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
Ok(sqlx::query_scalar("SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name")
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUser> {
|
||||
|
||||
@@ -89,10 +89,7 @@ pub fn app(state: AppState) -> Router {
|
||||
.route("/healthz", axum::routing::get(routes::public::health))
|
||||
.merge(routes::api(&state))
|
||||
.nest_service("/files", ServeDir::new(state.cfg.files_dir()))
|
||||
.nest_service(
|
||||
"/uploads",
|
||||
tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())),
|
||||
)
|
||||
.nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())))
|
||||
.fallback_service(spa)
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
|
||||
@@ -30,7 +30,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
async fn shutdown() {
|
||||
let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); };
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let term = async {
|
||||
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
|
||||
@@ -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.
|
||||
fn is_junk(rel: &str) -> bool {
|
||||
const DIRS: &[&str] = &[
|
||||
"logs/", "crash-reports/", "screenshots/", "versions/", "libraries/", "assets/", "natives/", "__MACOSX/", ".fabric/", ".scopenet/",
|
||||
"logs/",
|
||||
"crash-reports/",
|
||||
"screenshots/",
|
||||
"versions/",
|
||||
"libraries/",
|
||||
"assets/",
|
||||
"natives/",
|
||||
"__MACOSX/",
|
||||
".fabric/",
|
||||
".scopenet/",
|
||||
];
|
||||
const FILES: &[&str] = &[
|
||||
"usercache.json",
|
||||
"usernamecache.json",
|
||||
"launcher_profiles.json",
|
||||
"launcher_accounts.json",
|
||||
".DS_Store",
|
||||
"instance.cfg",
|
||||
"mmc-pack.json",
|
||||
];
|
||||
const FILES: &[&str] = &["usercache.json", "usernamecache.json", "launcher_profiles.json", "launcher_accounts.json", ".DS_Store", "instance.cfg", "mmc-pack.json"];
|
||||
DIRS.iter().any(|d| rel.starts_with(d)) || FILES.iter().any(|f| rel == *f || rel.ends_with(&format!("/{f}")))
|
||||
}
|
||||
|
||||
/// Extract entries under `prefix` into the instance's file store.
|
||||
fn extract_prefix(zip: &mut Zip, prefix: &str, dest_root: &Path, instance_id: &str, origin: &'static str, skip_junk: bool) -> AppResult<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();
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| AppError::bad_request(format!("corrupt zip: {e}")))?;
|
||||
@@ -139,7 +163,14 @@ fn import_mrpack(zip: &mut Zip, index: MrIndex, dest_root: &Path, instance_id: &
|
||||
if safe_join(dest_root, &f.path).is_none() {
|
||||
continue;
|
||||
}
|
||||
files.push(NewFile { path: f.path.replace('\\', "/"), url: url.clone(), sha1: sha1.clone(), size: f.file_size, origin: "pack", note: None });
|
||||
files.push(NewFile {
|
||||
path: f.path.replace('\\', "/"),
|
||||
url: url.clone(),
|
||||
sha1: sha1.clone(),
|
||||
size: f.file_size,
|
||||
origin: "pack",
|
||||
note: None,
|
||||
});
|
||||
}
|
||||
// client-overrides win over overrides.
|
||||
let mut overrides = extract_prefix(zip, "overrides/", dest_root, instance_id, "override", false)?;
|
||||
@@ -410,7 +441,9 @@ pub async fn fetch_curseforge(state: &AppState, mod_id: i64, file_id: i64) -> Ap
|
||||
}
|
||||
let info: Info = cf_get(state, &key, &format!("/mods/{mod_id}/files/{file_id}")).await?;
|
||||
let project: ModInfo = cf_get(state, &key, &format!("/mods/{mod_id}")).await?;
|
||||
let url = info.download_url.ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?;
|
||||
let url = info
|
||||
.download_url
|
||||
.ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?;
|
||||
let bytes = download_bytes(state, &url).await?;
|
||||
Ok((bytes, format!("CurseForge · {} ({})", project.name, info.display_name), project.logo.and_then(|l| l.thumbnail_url)))
|
||||
}
|
||||
@@ -464,7 +497,10 @@ pub async fn import_zip(state: &AppState, instance_id: &str, bytes: Vec<u8>, fal
|
||||
let (loader, loader_version) = primary.map(|l| parse_cf_loader(&mc, &l.id)).unwrap_or((Loader::Vanilla, None));
|
||||
let mut files = resolve_cf_files(state, &manifest.files).await?;
|
||||
merge_files(&mut files, overrides);
|
||||
return Ok((PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files }, "curseforge"));
|
||||
return Ok((
|
||||
PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files },
|
||||
"curseforge",
|
||||
));
|
||||
}
|
||||
|
||||
let (mut info, kind) = parsed.expect("parsed");
|
||||
@@ -497,7 +533,14 @@ pub fn detect_root(names: &[String]) -> String {
|
||||
|
||||
/// Replace the instance's pack/override files with `info.files`, keep files
|
||||
/// the admin uploaded by hand, update versions and bump the revision.
|
||||
pub async fn apply(state: &AppState, instance_id: &str, info: &PackInfo, kind: &str, label: &str, source_ref: serde_json::Value) -> AppResult<()> {
|
||||
pub async fn apply(
|
||||
state: &AppState,
|
||||
instance_id: &str,
|
||||
info: &PackInfo,
|
||||
kind: &str,
|
||||
label: &str,
|
||||
source_ref: serde_json::Value,
|
||||
) -> AppResult<()> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND origin != 'upload'").bind(instance_id).execute(&mut *tx).await?;
|
||||
for f in &info.files {
|
||||
@@ -537,12 +580,8 @@ pub async fn apply(state: &AppState, instance_id: &str, info: &PackInfo, kind: &
|
||||
/// Delete stored files no longer referenced by the instance.
|
||||
pub async fn gc_files(state: &AppState, instance_id: &str) -> AppResult<()> {
|
||||
let root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id));
|
||||
let referenced: std::collections::HashSet<String> = store::instance_files(state, instance_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|f| f.url.starts_with("/files/"))
|
||||
.map(|f| f.path)
|
||||
.collect();
|
||||
let referenced: std::collections::HashSet<String> =
|
||||
store::instance_files(state, instance_id).await?.into_iter().filter(|f| f.url.starts_with("/files/")).map(|f| f.path).collect();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
fn walk(dir: &Path, root: &Path, keep: &std::collections::HashSet<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
|
||||
@@ -35,12 +35,10 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
|
||||
.await?;
|
||||
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?;
|
||||
let players_7d: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?",
|
||||
)
|
||||
.bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339())
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let players_7d: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?")
|
||||
.bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339())
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
// Fill in empty days so the chart is continuous.
|
||||
let mut days = Vec::new();
|
||||
@@ -81,7 +79,8 @@ async fn view(state: &AppState, user: UserRow) -> AppResult<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());
|
||||
for u in users {
|
||||
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?))
|
||||
}
|
||||
|
||||
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 = ?")
|
||||
.bind(id)
|
||||
.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"))?;
|
||||
if let Some(password) = input.password.filter(|p| !p.is_empty()) {
|
||||
auth::validate_password(&password)?;
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?").bind(auth::hash_password(&password)?).bind(id).execute(&state.db).await?;
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(auth::hash_password(&password)?)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
if let Some(email) = input.email {
|
||||
sqlx::query("UPDATE users SET email = ? WHERE id = ?").bind(Some(email.trim()).filter(|e| !e.is_empty())).bind(id).execute(&state.db).await?;
|
||||
sqlx::query("UPDATE users SET email = ? WHERE id = ?")
|
||||
.bind(Some(email.trim()).filter(|e| !e.is_empty()))
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
if let Some(role) = input.role {
|
||||
check_role(&role)?;
|
||||
@@ -358,7 +370,11 @@ async fn validated(state: &AppState, mut input: InstanceInput) -> AppResult<Inst
|
||||
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 id = store::unique_slug(&state, &input.name).await?;
|
||||
let now = crate::db::now();
|
||||
@@ -409,7 +425,12 @@ async fn detail(state: &AppState, id: String) -> AppResult<Json<InstanceDetail>>
|
||||
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 input = validated(&state, input).await?;
|
||||
let mem = input.memory.unwrap_or_default();
|
||||
@@ -473,7 +494,12 @@ pub struct ModrinthImport {
|
||||
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 (bytes, label, icon) = packs::fetch_modrinth(&state, &req.version_id).await?;
|
||||
let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?;
|
||||
@@ -488,7 +514,12 @@ pub struct CurseForgeImport {
|
||||
file_id: i64,
|
||||
}
|
||||
|
||||
pub async fn import_curseforge(_: AdminUser, State(state): State<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 (bytes, label, icon) = packs::fetch_curseforge(&state, req.mod_id, req.file_id).await?;
|
||||
let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?;
|
||||
@@ -507,7 +538,12 @@ async fn set_icon_if_missing(state: &AppState, row: &store::InstanceRow, icon: O
|
||||
}
|
||||
|
||||
/// Upload a .mrpack / CurseForge zip / plain instance zip.
|
||||
pub async fn import_upload(_: AdminUser, State(state): State<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?;
|
||||
let mut bytes = None;
|
||||
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?;
|
||||
if kind == "zip" && info.loader != Loader::Vanilla {
|
||||
info.loader_version =
|
||||
scopenet_core::meta::resolve_loader_version(&state.http, info.loader, &info.mc_version, info.loader_version.as_deref()).await.ok().flatten();
|
||||
scopenet_core::meta::resolve_loader_version(&state.http, info.loader, &info.mc_version, info.loader_version.as_deref())
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
}
|
||||
let label = match kind {
|
||||
"modrinth" | "curseforge" if !info.name.is_empty() => format!("{} {}", info.name, info.version).trim().to_string(),
|
||||
@@ -540,7 +579,12 @@ pub async fn import_upload(_: AdminUser, State(state): State<AppState>, Path(id)
|
||||
|
||||
/// Add individual files (extra mods, configs, or a CurseForge mod that
|
||||
/// can't be downloaded automatically).
|
||||
pub async fn upload_files(_: AdminUser, State(state): State<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?;
|
||||
let root = state.cfg.files_dir().join(sanitize_id(&id));
|
||||
let mut folder = String::from("mods");
|
||||
@@ -591,7 +635,12 @@ pub struct PathQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub async fn delete_file(_: AdminUser, State(state): State<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?;
|
||||
store::bump_revision(&state, &id).await?;
|
||||
packs::gc_files(&state, &id).await?;
|
||||
|
||||
@@ -20,11 +20,7 @@ pub struct McVersion {
|
||||
|
||||
pub async fn minecraft(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<serde_json::Value>> {
|
||||
let m = meta::mojang_manifest(&state.http).await?;
|
||||
let versions: Vec<McVersion> = m
|
||||
.versions
|
||||
.into_iter()
|
||||
.map(|v| McVersion { id: v.id, kind: v.kind, released: v.release_time })
|
||||
.collect();
|
||||
let versions: Vec<McVersion> = m.versions.into_iter().map(|v| McVersion { id: v.id, kind: v.kind, released: v.release_time }).collect();
|
||||
Ok(Json(serde_json::json!({ "latest": m.latest, "versions": versions })))
|
||||
}
|
||||
|
||||
@@ -33,7 +29,12 @@ pub struct LoaderQuery {
|
||||
mc: String,
|
||||
}
|
||||
|
||||
pub async fn loaders(_: AdminUser, State(state): State<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();
|
||||
Ok(Json(meta::loader_versions(&state.http, loader, &q.mc).await?))
|
||||
}
|
||||
@@ -44,7 +45,11 @@ pub struct SearchQuery {
|
||||
q: String,
|
||||
}
|
||||
|
||||
pub async fn modrinth_search(_: AdminUser, State(state): State<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
|
||||
.http
|
||||
.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)?))
|
||||
}
|
||||
|
||||
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));
|
||||
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>> {
|
||||
let path = format!(
|
||||
"/mods/search?gameId=432&classId=4471&pageSize=24&sortField=2&sortOrder=desc&searchFilter={}",
|
||||
urlencode(&q.q)
|
||||
);
|
||||
pub async fn curseforge_search(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
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?))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 groups = match &user {
|
||||
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?))
|
||||
}
|
||||
|
||||
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 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 (?, ?, ?, ?)")
|
||||
|
||||
@@ -193,12 +193,11 @@ pub struct 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(
|
||||
"SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let (count, size, missing): (i64, Option<i64>, Option<i64>) =
|
||||
sqlx::query_as("SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?")
|
||||
.bind(instance_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 })
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,12 @@ fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec<u8>)
|
||||
for (k, v) in fields {
|
||||
write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap();
|
||||
}
|
||||
write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", file.0).unwrap();
|
||||
write!(
|
||||
body,
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
|
||||
file.0
|
||||
)
|
||||
.unwrap();
|
||||
body.extend_from_slice(file.1);
|
||||
write!(body, "\r\n--{boundary}--\r\n").unwrap();
|
||||
(format!("multipart/form-data; boundary={boundary}"), body)
|
||||
@@ -105,9 +110,7 @@ async fn admin_only_routes_are_guarded() {
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (s, v) = t
|
||||
.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"})))
|
||||
.await;
|
||||
let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve"));
|
||||
|
||||
@@ -188,12 +191,20 @@ async fn instance_visibility_and_zip_upload() {
|
||||
// Plain zip without version info → needs the fallback fields.
|
||||
let zip = zip_bytes(&[("MyPack/mods/cool.jar", b"jarjar"), ("MyPack/config/x.toml", b"a=1"), ("MyPack/logs/latest.log", b"junk")]);
|
||||
let (ct, body) = multipart(&[], ("pack.zip", &zip));
|
||||
let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
|
||||
let req = Request::post("/api/admin/instances/public/import/upload")
|
||||
.header("authorization", format!("Bearer {admin}"))
|
||||
.header("content-type", &ct)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let (s, v) = t.send(req).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
|
||||
|
||||
let (ct, body) = multipart(&[("mc_version", "1.20.1"), ("loader", "vanilla")], ("pack.zip", &zip));
|
||||
let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
|
||||
let req = Request::post("/api/admin/instances/public/import/upload")
|
||||
.header("authorization", format!("Bearer {admin}"))
|
||||
.header("content-type", &ct)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let (s, v) = t.send(req).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let paths: Vec<&str> = v["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect();
|
||||
@@ -232,7 +243,11 @@ async fn mrpack_upload_sets_versions_and_overrides() {
|
||||
("client-overrides/config/a.json", b"{\"client\":1}"),
|
||||
]);
|
||||
let (ct, body) = multipart(&[], ("fab.mrpack", &zip));
|
||||
let req = Request::post("/api/admin/instances/pack/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
|
||||
let req = Request::post("/api/admin/instances/pack/import/upload")
|
||||
.header("authorization", format!("Bearer {admin}"))
|
||||
.header("content-type", &ct)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let (s, v) = t.send(req).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["instance"]["mc_version"], "1.21.1");
|
||||
@@ -261,11 +276,19 @@ async fn branding_roundtrip_and_media_validation() {
|
||||
assert_eq!(m["branding"]["colors"]["accent"], "#ff5500");
|
||||
|
||||
let (ct, body) = multipart(&[], ("evil.svg", b"<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;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let (ct, body) = multipart(&[], ("logo.png", b"\x89PNG"));
|
||||
let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
|
||||
let req = Request::post("/api/admin/uploads")
|
||||
.header("authorization", format!("Bearer {admin}"))
|
||||
.header("content-type", &ct)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
let (s, v) = t.send(req).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert!(v["url"].as_str().unwrap().starts_with("/uploads/"));
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
max_width = 140
|
||||
use_small_heuristics = "Max"
|
||||