mirror of
https://github.com/Xevion/dynamic-preauth.git
synced 2025-12-14 22:11:31 -06:00
Compare commits
20 Commits
8431290ea7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f86a2db161 | |||
| 4a191a59f4 | |||
| b4022ff9db | |||
| 2532a21772 | |||
| fd474767ae | |||
| 65aa9d66d3 | |||
| e23c01e4fd | |||
| d4454d7367 | |||
| 1a2b8c4407 | |||
| 702205e181 | |||
| 006055cb7f | |||
| 8129975ecc | |||
| 3ba9250cca | |||
| 82ac8caa88 | |||
| a9e3ab8337 | |||
| 24c2c2b3c5 | |||
| 280f01bb28 | |||
| 1ffdd2b6eb | |||
| e0bb0f52f0 | |||
| d20f298da5 |
45
.dockerignore
Normal file
45
.dockerignore
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
target/
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Frontend build output
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.next/
|
||||||
|
frontend/out/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github/
|
||||||
|
|
||||||
|
# Other
|
||||||
|
Justfile
|
||||||
|
.dockerignore
|
||||||
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# optional, used for fetching build logs, not configured automatically
|
||||||
|
RAILWAY_TOKEN=your_railway_token_here
|
||||||
|
|
||||||
|
# optional but automatically configured by Railway
|
||||||
|
# RAILWAY_PROJECT_ID=your_project_id_here
|
||||||
|
# RAILWAY_SERVICE_ID=your_service_id_here
|
||||||
|
# RAILWAY_ENVIRONMENT_ID=your_environment_id_here
|
||||||
|
# RAILWAY_DEPLOYMENT_ID=your_deployment_id_here
|
||||||
|
|
||||||
|
# optional, automatically configured by Railway
|
||||||
|
# PORT=5800
|
||||||
|
|
||||||
|
# optional, has a default you may not want
|
||||||
|
# RAILWAY_PUBLIC_DOMAIN=your-domain.railway.app
|
||||||
42
.github/renovate.json
vendored
Normal file
42
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":dependencyDashboard",
|
||||||
|
":semanticCommits",
|
||||||
|
":automergeDigest",
|
||||||
|
":automergeMinor"
|
||||||
|
],
|
||||||
|
"schedule": ["before 5am on monday"],
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"prConcurrentLimit": 3,
|
||||||
|
"prCreation": "not-pending",
|
||||||
|
"rebaseWhen": "behind-base-branch",
|
||||||
|
"semanticCommitScope": "deps",
|
||||||
|
"vulnerabilityAlerts": {
|
||||||
|
"labels": ["security"],
|
||||||
|
"automerge": true,
|
||||||
|
"schedule": ["at any time"]
|
||||||
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Group all non-major dependency updates together",
|
||||||
|
"groupName": "all non-major dependencies",
|
||||||
|
"matchUpdateTypes": ["minor", "patch", "digest"],
|
||||||
|
"automerge": true,
|
||||||
|
"automergeType": "pr",
|
||||||
|
"minimumReleaseAge": "3 days"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Major updates get individual PRs for review",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"automerge": false,
|
||||||
|
"minimumReleaseAge": "7 days"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"postUpdateOptions": ["pnpmDedupe"],
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": ["before 5am on monday"]
|
||||||
|
}
|
||||||
|
}
|
||||||
134
.github/workflows/quality.yaml
vendored
Normal file
134
.github/workflows/quality.yaml
vendored
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
name: Quality
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format:
|
||||||
|
name: Format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
name: Clippy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Run clippy
|
||||||
|
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
audit:
|
||||||
|
name: Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: taiki-e/install-action@cargo-audit
|
||||||
|
|
||||||
|
- name: Run audit
|
||||||
|
run: cargo audit
|
||||||
|
|
||||||
|
check:
|
||||||
|
name: Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Run check
|
||||||
|
run: cargo check --workspace --all-targets --all-features
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: pnpm astro check
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
|||||||
demo-*
|
demo-*
|
||||||
/public
|
/public
|
||||||
.env
|
.env
|
||||||
|
.overmind.sock
|
||||||
|
|||||||
1451
Cargo.lock
generated
1451
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
25
Cargo.toml
@@ -1,17 +1,34 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "dynamic-preauth"
|
resolver = "2"
|
||||||
|
members = ["backend", "demo"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0.95"
|
anyhow = "1.0.95"
|
||||||
chrono = { version = "0.4.39", features = ["serde"] }
|
chrono = { version = "0.4.39", features = ["serde"] }
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
envy = "0.4.2"
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
|
hex = "0.4.3"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
salvo = { version = "0.74.3", features = ["affix-state", "catch-panic", "cors", "logging", "serve-static", "websocket"] }
|
regex = "1.10"
|
||||||
|
reqwest = { version = "0.12", default-features = false }
|
||||||
|
salvo = { version = "0.74.3", features = ["affix-state", "catch-panic", "cors", "logging", "serve-static", "test", "websocket"] }
|
||||||
serde = { version = "1.0.216", features = ["derive"] }
|
serde = { version = "1.0.216", features = ["derive"] }
|
||||||
serde_json = "1.0.134"
|
serde_json = "1.0.134"
|
||||||
|
sha2 = "0.10.8"
|
||||||
tokio = { version = "1", features = ["macros"] }
|
tokio = { version = "1", features = ["macros"] }
|
||||||
tokio-stream = "0.1.17"
|
tokio-stream = "0.1.17"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
strip = "debuginfo"
|
||||||
|
panic = "abort"
|
||||||
|
codegen-units = 1
|
||||||
|
overflow-checks = true
|
||||||
|
|||||||
160
Dockerfile
160
Dockerfile
@@ -1,55 +1,137 @@
|
|||||||
# Build the demo application
|
# syntax=docker/dockerfile:1
|
||||||
FROM rust:latest AS builder-demo
|
ARG RUST_VERSION=1.86.0
|
||||||
|
|
||||||
WORKDIR /build/demo
|
# --- Chef Base Stage ---
|
||||||
RUN apt update && apt install -y g++-mingw-w64-x86-64
|
FROM lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION} AS chef
|
||||||
ARG RAILWAY_PUBLIC_DOMAIN
|
WORKDIR /app
|
||||||
|
|
||||||
RUN rustup target add x86_64-pc-windows-gnu
|
# --- Demo Planner Stage ---
|
||||||
RUN rustup target add x86_64-unknown-linux-gnu
|
FROM chef AS demo-planner
|
||||||
# TODO: Add support for macOS
|
COPY Cargo.toml Cargo.lock ./
|
||||||
# RUN rustup target add x86_64-apple-darwin
|
COPY backend ./backend
|
||||||
|
COPY demo ./demo
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json --bin demo
|
||||||
|
|
||||||
COPY ./demo ./
|
# --- Demo Builder Stage ---
|
||||||
|
FROM chef AS demo-builder
|
||||||
|
|
||||||
RUN cargo build --release --target x86_64-pc-windows-gnu
|
# Install cross-compilation toolchain for Windows
|
||||||
RUN cargo build --release --target x86_64-unknown-linux-gnu
|
RUN apt-get update && apt-get install -y \
|
||||||
# RUN cargo build --release --target x86_64-apple-darwin
|
g++-mingw-w64-x86-64 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Build the server application
|
# Add cross-compilation targets
|
||||||
FROM rust:alpine AS builder-server
|
RUN rustup target add x86_64-pc-windows-gnu x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
RUN apk update && apk add musl-dev
|
# Copy recipe and cook dependencies
|
||||||
WORKDIR /build/server
|
COPY --from=demo-planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --target x86_64-unknown-linux-gnu --recipe-path recipe.json --bin demo
|
||||||
|
RUN cargo chef cook --release --target x86_64-pc-windows-gnu --recipe-path recipe.json --bin demo
|
||||||
|
|
||||||
COPY ./src ./src
|
# Copy source and build
|
||||||
COPY ./Cargo.toml ./Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
RUN cargo build --release
|
COPY backend ./backend
|
||||||
|
COPY demo ./demo
|
||||||
# Build the Astro frontend
|
|
||||||
FROM node:lts AS builder-astro
|
|
||||||
|
|
||||||
WORKDIR /build/astro
|
|
||||||
|
|
||||||
COPY ./frontend/ ./
|
|
||||||
|
|
||||||
ARG RAILWAY_PUBLIC_DOMAIN
|
ARG RAILWAY_PUBLIC_DOMAIN
|
||||||
ENV RAILWAY_PUBLIC_DOMAIN=${RAILWAY_PUBLIC_DOMAIN}
|
ENV RAILWAY_PUBLIC_DOMAIN=${RAILWAY_PUBLIC_DOMAIN}
|
||||||
|
|
||||||
RUN npm install pnpm -g
|
RUN cargo build --release --target x86_64-unknown-linux-gnu --bin demo
|
||||||
RUN pnpm install
|
RUN cargo build --release --target x86_64-pc-windows-gnu --bin demo
|
||||||
RUN pnpm build
|
|
||||||
RUN ./compress.sh
|
|
||||||
|
|
||||||
# Run the server application
|
# Strip binaries
|
||||||
FROM alpine:latest
|
RUN strip target/x86_64-unknown-linux-gnu/release/demo
|
||||||
|
|
||||||
|
# --- Server Planner Stage ---
|
||||||
|
FROM chef AS server-planner
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY backend ./backend
|
||||||
|
COPY demo ./demo
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
# --- Server Builder Stage ---
|
||||||
|
FROM chef AS server-builder
|
||||||
|
|
||||||
|
# Copy recipe and cook dependencies
|
||||||
|
COPY --from=server-planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY backend ./backend
|
||||||
|
COPY demo ./demo
|
||||||
|
RUN cargo build --release --bin dynamic-preauth
|
||||||
|
|
||||||
|
# Strip binary
|
||||||
|
RUN strip target/release/dynamic-preauth
|
||||||
|
|
||||||
|
# --- Frontend Builder Stage ---
|
||||||
|
FROM node:22-slim AS frontend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder-astro /build/astro/dist/ ./public/
|
# Install pnpm
|
||||||
COPY --from=builder-demo /build/demo/target/x86_64-pc-windows-gnu/release/demo.exe ./demo.exe
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
COPY --from=builder-demo /build/demo/target/x86_64-unknown-linux-gnu/release/demo ./demo-linux
|
|
||||||
COPY --from=builder-server /build/server/target/release/dynamic-preauth ./dynamic-preauth
|
|
||||||
|
|
||||||
EXPOSE 5800
|
# Copy package files for layer caching
|
||||||
CMD ["/app/dynamic-preauth"]
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
ARG RAILWAY_PUBLIC_DOMAIN
|
||||||
|
ENV RAILWAY_PUBLIC_DOMAIN=${RAILWAY_PUBLIC_DOMAIN}
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Pre-compress static assets
|
||||||
|
RUN ./compress.sh
|
||||||
|
|
||||||
|
# --- Runtime Stage ---
|
||||||
|
FROM debian:12-slim
|
||||||
|
|
||||||
|
ARG APP=/app
|
||||||
|
ARG APP_USER=appuser
|
||||||
|
ARG UID=1000
|
||||||
|
ARG GID=1000
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
tzdata \
|
||||||
|
wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG TZ=Etc/UTC
|
||||||
|
ENV TZ=${TZ}
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --gid $GID $APP_USER \
|
||||||
|
&& adduser --uid $UID --disabled-password --gecos "" --ingroup $APP_USER $APP_USER \
|
||||||
|
&& mkdir -p ${APP}
|
||||||
|
|
||||||
|
WORKDIR ${APP}
|
||||||
|
|
||||||
|
# Copy built artifacts
|
||||||
|
COPY --from=frontend-builder --chown=$APP_USER:$APP_USER /app/dist/ ./public/
|
||||||
|
COPY --from=demo-builder --chown=$APP_USER:$APP_USER /app/target/x86_64-pc-windows-gnu/release/demo.exe ./demo.exe
|
||||||
|
COPY --from=demo-builder --chown=$APP_USER:$APP_USER /app/target/x86_64-unknown-linux-gnu/release/demo ./demo-linux
|
||||||
|
COPY --from=server-builder --chown=$APP_USER:$APP_USER /app/target/release/dynamic-preauth ./dynamic-preauth
|
||||||
|
|
||||||
|
# Set proper permissions
|
||||||
|
RUN chmod +x ${APP}/dynamic-preauth
|
||||||
|
|
||||||
|
USER $APP_USER
|
||||||
|
|
||||||
|
# Build-time arg for PORT, default to 5800
|
||||||
|
ARG PORT=5800
|
||||||
|
ENV PORT=${PORT}
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT}/session || exit 1
|
||||||
|
|
||||||
|
CMD ["./dynamic-preauth"]
|
||||||
|
|||||||
191
Justfile
Normal file
191
Justfile
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Justfile for dynamic-preauth
|
||||||
|
# Uses bacon for Rust watching, pnpm for frontend
|
||||||
|
# Frontend builds to ./public, which backend serves as static files
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
image_name := "dynamic-preauth"
|
||||||
|
container_name := "dynamic-preauth-dev"
|
||||||
|
port := "5800"
|
||||||
|
|
||||||
|
# Default recipe
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# Run all checks (matches quality workflow)
|
||||||
|
check: format-check cargo-check lint audit frontend-check frontend-build
|
||||||
|
@echo "All checks passed!"
|
||||||
|
|
||||||
|
# Format all Rust code
|
||||||
|
format:
|
||||||
|
@echo "Formatting code..."
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
# Check formatting without modifying
|
||||||
|
format-check:
|
||||||
|
@echo "Checking formatting..."
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
|
||||||
|
# Check code without building
|
||||||
|
cargo-check:
|
||||||
|
@echo "Running cargo check..."
|
||||||
|
cargo check --workspace --all-targets --all-features
|
||||||
|
|
||||||
|
# Lint with clippy
|
||||||
|
lint:
|
||||||
|
@echo "Running clippy..."
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
# Frontend type check
|
||||||
|
frontend-check:
|
||||||
|
@echo "Checking frontend..."
|
||||||
|
pnpm --dir frontend astro check
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
frontend-build:
|
||||||
|
@echo "Building frontend..."
|
||||||
|
pnpm --dir frontend build
|
||||||
|
|
||||||
|
# Build demo executables (debug mode for faster dev builds)
|
||||||
|
build-demo:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Building demo executables..."
|
||||||
|
|
||||||
|
# Always build Linux demo
|
||||||
|
echo "Building Linux demo..."
|
||||||
|
cargo build --bin demo
|
||||||
|
cp target/debug/demo ./demo-linux
|
||||||
|
echo " [OK] Linux demo built"
|
||||||
|
|
||||||
|
# Try to build Windows demo if cross-compilation is available
|
||||||
|
if rustup target list --installed | grep -q x86_64-pc-windows-gnu; then
|
||||||
|
echo "Building Windows demo..."
|
||||||
|
if cargo build --bin demo --target x86_64-pc-windows-gnu 2>/dev/null; then
|
||||||
|
cp target/x86_64-pc-windows-gnu/debug/demo.exe ./demo.exe
|
||||||
|
echo " [OK] Windows demo built"
|
||||||
|
else
|
||||||
|
echo " [!] Windows build failed (mingw-w64 toolchain may not be installed)"
|
||||||
|
echo " Continuing without Windows demo..."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " [SKIP] Windows target not installed"
|
||||||
|
echo " Install with: rustup target add x86_64-pc-windows-gnu"
|
||||||
|
echo " Also requires: sudo apt install mingw-w64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Demo executables ready!"
|
||||||
|
|
||||||
|
# Development server with hot reload (backend + frontend using Overmind)
|
||||||
|
dev: build-demo
|
||||||
|
@echo "Starting development servers with Overmind..."
|
||||||
|
@echo ""
|
||||||
|
@echo "Backend will run on: http://localhost:5800"
|
||||||
|
@echo "Frontend will run on: http://localhost:4321"
|
||||||
|
@echo ""
|
||||||
|
@echo "Overmind multiplexes logs with prefixes:"
|
||||||
|
@echo " [backend] - Bacon watching Rust backend"
|
||||||
|
@echo " [frontend] - Astro dev server"
|
||||||
|
@echo ""
|
||||||
|
@echo "Overmind shortcuts:"
|
||||||
|
@echo " Ctrl+C - Stop all processes"
|
||||||
|
@echo " 'overmind connect <process>' - Attach to a specific process"
|
||||||
|
@echo ""
|
||||||
|
overmind start -f Procfile.dev
|
||||||
|
|
||||||
|
# Watch backend only (for when frontend is already built)
|
||||||
|
dev-backend: build-demo
|
||||||
|
@echo "Starting backend watch with bacon..."
|
||||||
|
bacon run
|
||||||
|
|
||||||
|
# Watch and serve frontend only
|
||||||
|
dev-frontend:
|
||||||
|
@echo "Starting frontend dev server..."
|
||||||
|
@echo "Make sure the backend is running on port 5800!"
|
||||||
|
pnpm --dir frontend dev
|
||||||
|
|
||||||
|
# Simple development run (no hot reload)
|
||||||
|
run:
|
||||||
|
@echo "Starting server..."
|
||||||
|
cargo run --bin dynamic-preauth
|
||||||
|
|
||||||
|
# Build release
|
||||||
|
build:
|
||||||
|
@echo "Building release..."
|
||||||
|
cargo build --workspace --release
|
||||||
|
|
||||||
|
# Security audit
|
||||||
|
audit:
|
||||||
|
@echo "Running security audit..."
|
||||||
|
cargo audit
|
||||||
|
|
||||||
|
# Build Docker image (ensures frontend is built first)
|
||||||
|
docker-build: frontend-build
|
||||||
|
@echo "Building Docker image..."
|
||||||
|
docker build -t {{image_name}}:latest .
|
||||||
|
|
||||||
|
# Run Docker container
|
||||||
|
docker-run: docker-build
|
||||||
|
@echo "Running Docker container..."
|
||||||
|
docker run --rm -d --name {{container_name}} -p {{port}}:{{port}} -e PORT={{port}} {{image_name}}:latest
|
||||||
|
@echo "Container started at http://localhost:{{port}}"
|
||||||
|
|
||||||
|
# Stop Docker container
|
||||||
|
docker-stop:
|
||||||
|
@echo "Stopping Docker container..."
|
||||||
|
docker stop {{container_name}} || true
|
||||||
|
|
||||||
|
# Docker logs
|
||||||
|
docker-logs:
|
||||||
|
docker logs {{container_name}}
|
||||||
|
|
||||||
|
# Follow Docker logs
|
||||||
|
docker-logs-follow:
|
||||||
|
docker logs -f {{container_name}}
|
||||||
|
|
||||||
|
# Clean Docker artifacts
|
||||||
|
docker-clean: docker-stop
|
||||||
|
@echo "Cleaning Docker artifacts..."
|
||||||
|
docker rmi {{image_name}}:latest || true
|
||||||
|
|
||||||
|
# Clean cargo artifacts
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning cargo artifacts..."
|
||||||
|
cargo clean
|
||||||
|
|
||||||
|
# Full CI pipeline
|
||||||
|
ci: format-check lint frontend-check build docker-build
|
||||||
|
@echo "CI pipeline completed!"
|
||||||
|
|
||||||
|
# Quick development check (format + clippy)
|
||||||
|
quick: format
|
||||||
|
@echo "Running quick clippy check..."
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
@echo "Quick check completed!"
|
||||||
|
|
||||||
|
# Verify dev setup is ready (builds demo executables and checks dependencies)
|
||||||
|
smoke: build-demo
|
||||||
|
@echo "Verifying development setup..."
|
||||||
|
@echo ""
|
||||||
|
@echo "Checking for overmind (required for 'just dev')..."
|
||||||
|
@command -v overmind >/dev/null 2>&1 || { echo " [!] overmind not found. Install from: https://github.com/DarthSim/overmind#installation"; exit 1; }
|
||||||
|
@echo " [OK] overmind found"
|
||||||
|
@echo ""
|
||||||
|
@echo "Checking for bacon..."
|
||||||
|
@command -v bacon >/dev/null 2>&1 || { echo " [!] bacon not found. Install with: cargo install bacon"; exit 1; }
|
||||||
|
@echo " [OK] bacon found"
|
||||||
|
@echo ""
|
||||||
|
@echo "Checking for pnpm..."
|
||||||
|
@command -v pnpm >/dev/null 2>&1 || { echo " [!] pnpm not found. Install from: https://pnpm.io/installation"; exit 1; }
|
||||||
|
@echo " [OK] pnpm found"
|
||||||
|
@echo ""
|
||||||
|
@echo "Checking demo executables..."
|
||||||
|
@test -f ./demo-linux || { echo " [!] demo-linux not found"; exit 1; }
|
||||||
|
@echo " [OK] demo-linux exists"
|
||||||
|
@if [ -f ./demo.exe ]; then \
|
||||||
|
echo " [OK] demo.exe exists"; \
|
||||||
|
else \
|
||||||
|
echo " [SKIP] demo.exe not found (Windows builds not available)"; \
|
||||||
|
fi
|
||||||
|
@echo ""
|
||||||
|
@echo "[OK] Development setup is ready! Run 'just dev' to start."
|
||||||
5
Procfile.dev
Normal file
5
Procfile.dev
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Procfile for Overmind development workflow
|
||||||
|
# Start with: overmind start -f Procfile.dev
|
||||||
|
|
||||||
|
backend: PORT=5800 bacon run --headless
|
||||||
|
frontend: pnpm --dir frontend dev
|
||||||
26
backend/Cargo.toml
Normal file
26
backend/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "dynamic-preauth"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "dynamic-preauth"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
dotenvy.workspace = true
|
||||||
|
envy.workspace = true
|
||||||
|
futures-util.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
|
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
|
||||||
|
salvo.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror = "2.0.17"
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-stream.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
70
backend/src/config.rs
Normal file
70
backend/src/config.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
fn default_port() -> u16 {
|
||||||
|
5800
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Railway-specific configuration parsed from environment variables.
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
pub struct RailwayConfig {
|
||||||
|
pub railway_token: Option<String>,
|
||||||
|
pub railway_project_id: Option<String>,
|
||||||
|
pub railway_service_id: Option<String>,
|
||||||
|
pub railway_environment_id: Option<String>,
|
||||||
|
pub railway_deployment_id: Option<String>,
|
||||||
|
pub railway_public_domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RailwayConfig {
|
||||||
|
/// Returns true if running on Railway (project ID is set).
|
||||||
|
pub fn is_railway(&self) -> bool {
|
||||||
|
self.railway_project_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if Railway API token is configured.
|
||||||
|
pub fn has_token(&self) -> bool {
|
||||||
|
self.railway_token.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the Railway dashboard URL for viewing build logs.
|
||||||
|
pub fn build_logs_url(&self) -> Option<String> {
|
||||||
|
let project_id = self.railway_project_id.as_ref()?;
|
||||||
|
let service_id = self.railway_service_id.as_ref()?;
|
||||||
|
let environment_id = self.railway_environment_id.as_ref()?;
|
||||||
|
let deployment_id = self.railway_deployment_id.as_deref().unwrap_or("latest");
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"https://railway.com/project/{}/service/{}?environmentId={}&id={}#build",
|
||||||
|
project_id, service_id, environment_id, deployment_id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the CORS origin based on public domain.
|
||||||
|
pub fn cors_origin(&self) -> String {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
return "*".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.railway_public_domain {
|
||||||
|
Some(domain) => format!("https://{}", domain),
|
||||||
|
None => "*".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main configuration struct parsed from environment variables.
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_port")]
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub railway: RailwayConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Returns the socket address to bind to.
|
||||||
|
pub fn bind_addr(&self) -> String {
|
||||||
|
format!("0.0.0.0:{}", self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/errors.rs
Normal file
19
backend/src/errors.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, AppError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("executable not found at '{path}'")]
|
||||||
|
ExecutableNotFound { path: PathBuf },
|
||||||
|
|
||||||
|
#[error("key pattern not found in executable '{name}'")]
|
||||||
|
KeyPatternNotFound { name: String },
|
||||||
|
|
||||||
|
#[error("missing required environment variable '{name}'")]
|
||||||
|
MissingEnvVar { name: String },
|
||||||
|
|
||||||
|
#[error("configuration error: {message}")]
|
||||||
|
Config { message: String },
|
||||||
|
}
|
||||||
52
backend/src/handlers/build_logs.rs
Normal file
52
backend/src/handlers/build_logs.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use salvo::http::StatusCode;
|
||||||
|
use salvo::prelude::{handler, Request, Response};
|
||||||
|
use salvo::Depot;
|
||||||
|
|
||||||
|
use crate::state::STORE;
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn get_build_logs(req: &mut Request, res: &mut Response, _depot: &mut Depot) {
|
||||||
|
let store = STORE.lock().await;
|
||||||
|
|
||||||
|
if let Some(build_logs) = &store.build_logs {
|
||||||
|
// Use pre-computed hash for ETag
|
||||||
|
let etag = format!("\"{:x}\"", build_logs.content_hash);
|
||||||
|
|
||||||
|
// Check If-None-Match header
|
||||||
|
if let Some(if_none_match) = req.headers().get("If-None-Match") {
|
||||||
|
if if_none_match == &etag {
|
||||||
|
res.status_code(StatusCode::NOT_MODIFIED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check If-Modified-Since header
|
||||||
|
if let Some(if_modified_since) = req.headers().get("If-Modified-Since") {
|
||||||
|
if let Ok(if_modified_since_str) = if_modified_since.to_str() {
|
||||||
|
if let Ok(if_modified_since_time) =
|
||||||
|
chrono::DateTime::parse_from_rfc2822(if_modified_since_str)
|
||||||
|
{
|
||||||
|
if build_logs.fetched_at <= if_modified_since_time {
|
||||||
|
res.status_code(StatusCode::NOT_MODIFIED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.headers_mut().insert("ETag", etag.parse().unwrap());
|
||||||
|
res.headers_mut()
|
||||||
|
.insert("Content-Type", "text/plain; charset=utf-8".parse().unwrap());
|
||||||
|
res.headers_mut()
|
||||||
|
.insert("Cache-Control", "public, max-age=300".parse().unwrap());
|
||||||
|
res.headers_mut().insert(
|
||||||
|
"Last-Modified",
|
||||||
|
build_logs.fetched_at.to_rfc2822().parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.render(&build_logs.content);
|
||||||
|
} else {
|
||||||
|
res.status_code(StatusCode::NOT_FOUND);
|
||||||
|
res.render("Build logs not available");
|
||||||
|
}
|
||||||
|
}
|
||||||
58
backend/src/handlers/downloads.rs
Normal file
58
backend/src/handlers/downloads.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use salvo::http::HeaderValue;
|
||||||
|
use salvo::prelude::{handler, Request, Response};
|
||||||
|
use salvo::Depot;
|
||||||
|
|
||||||
|
use crate::state::STORE;
|
||||||
|
|
||||||
|
use super::session::get_session_id;
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn download(req: &mut Request, res: &mut Response, depot: &mut Depot) {
|
||||||
|
let download_id = req
|
||||||
|
.param::<String>("id")
|
||||||
|
.expect("Download ID required to download file");
|
||||||
|
|
||||||
|
let session_id =
|
||||||
|
get_session_id(req, depot).expect("Session ID could not be found via request or depot");
|
||||||
|
|
||||||
|
let store = &mut *STORE.lock().await;
|
||||||
|
|
||||||
|
let session = store
|
||||||
|
.sessions
|
||||||
|
.get_mut(&session_id)
|
||||||
|
.expect("Session not found");
|
||||||
|
let executable = store
|
||||||
|
.executables
|
||||||
|
.get(&download_id as &str)
|
||||||
|
.expect("Executable not found");
|
||||||
|
|
||||||
|
// Create a download for the session
|
||||||
|
let session_download = session.add_download(executable);
|
||||||
|
tracing::info!(session_id, type = download_id, dl_token = session_download.token, "Download created");
|
||||||
|
let data = executable.with_key(session_download.token.to_string().as_bytes());
|
||||||
|
|
||||||
|
if let Err(e) = res.write_body(data) {
|
||||||
|
tracing::error!("Error writing body: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.headers.insert(
|
||||||
|
"Content-Disposition",
|
||||||
|
HeaderValue::from_str(
|
||||||
|
format!("attachment; filename=\"{}\"", session_download.filename).as_str(),
|
||||||
|
)
|
||||||
|
.expect("Unable to create header"),
|
||||||
|
);
|
||||||
|
res.headers.insert(
|
||||||
|
"Content-Type",
|
||||||
|
HeaderValue::from_static("application/octet-stream"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't try to send state if somehow the session has not connected
|
||||||
|
if session.tx.is_some() {
|
||||||
|
session
|
||||||
|
.send_state()
|
||||||
|
.expect("Failed to buffer state message");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Download being made without any connection websocket");
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/handlers/mod.rs
Normal file
11
backend/src/handlers/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mod build_logs;
|
||||||
|
mod downloads;
|
||||||
|
mod notifications;
|
||||||
|
mod session;
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
|
pub use build_logs::get_build_logs;
|
||||||
|
pub use downloads::download;
|
||||||
|
pub use notifications::notify;
|
||||||
|
pub use session::{get_session, session_middleware};
|
||||||
|
pub use websocket::connect;
|
||||||
61
backend/src/handlers/notifications.rs
Normal file
61
backend/src/handlers/notifications.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use salvo::http::StatusCode;
|
||||||
|
use salvo::prelude::{handler, Request, Response};
|
||||||
|
|
||||||
|
use crate::models::OutgoingMessage;
|
||||||
|
use crate::state::STORE;
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn notify(req: &mut Request, res: &mut Response) {
|
||||||
|
let key = req.query::<String>("key");
|
||||||
|
|
||||||
|
if key.is_none() {
|
||||||
|
res.status_code(StatusCode::BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = key.unwrap();
|
||||||
|
|
||||||
|
if !key.starts_with("0x") {
|
||||||
|
res.status_code(StatusCode::BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key into u32
|
||||||
|
let key = match u32::from_str_radix(key.trim_start_matches("0x"), 16) {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Error parsing key: {}", e);
|
||||||
|
res.status_code(StatusCode::BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let store = &mut *STORE.lock().await;
|
||||||
|
|
||||||
|
let target_session = store
|
||||||
|
.sessions
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(_, session)| session.downloads.iter().any(|d| d.token == key));
|
||||||
|
|
||||||
|
match target_session {
|
||||||
|
Some((_, session)) => {
|
||||||
|
let message = OutgoingMessage::TokenAlert { token: key };
|
||||||
|
|
||||||
|
if let Err(e) = session.send_message(message) {
|
||||||
|
tracing::warn!(
|
||||||
|
error = e.to_string(),
|
||||||
|
"Session did not have a receiving WebSocket available, notify ignored.",
|
||||||
|
);
|
||||||
|
res.status_code(StatusCode::NOT_MODIFIED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render("Notification sent");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Session not found for key while attempting notify: {}", key);
|
||||||
|
res.status_code(StatusCode::UNAUTHORIZED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
backend/src/handlers/session.rs
Normal file
85
backend/src/handlers/session.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use salvo::http::StatusCode;
|
||||||
|
use salvo::prelude::{handler, Request, Response};
|
||||||
|
use salvo::writing::Json;
|
||||||
|
use salvo::Depot;
|
||||||
|
|
||||||
|
use crate::state::STORE;
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn session_middleware(req: &mut Request, res: &mut Response, depot: &mut Depot) {
|
||||||
|
match req.cookie("Session") {
|
||||||
|
Some(cookie) => {
|
||||||
|
// Check if the session exists
|
||||||
|
match cookie.value().parse::<u32>() {
|
||||||
|
Ok(session_id) => {
|
||||||
|
let mut store = STORE.lock().await;
|
||||||
|
if !store.sessions.contains_key(&session_id) {
|
||||||
|
let new_session_id = store.new_session(res).await;
|
||||||
|
depot.insert("session_id", new_session_id);
|
||||||
|
tracing::debug!(
|
||||||
|
existing_session_id = session_id,
|
||||||
|
new_session_id = new_session_id,
|
||||||
|
"Session provided in cookie, but does not exist"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
store.sessions.get_mut(&session_id).unwrap().seen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(parse_error) => {
|
||||||
|
tracing::debug!(
|
||||||
|
invalid_session_id = cookie.value(),
|
||||||
|
error = ?parse_error,
|
||||||
|
"Session provided in cookie, but is not a valid number"
|
||||||
|
);
|
||||||
|
let mut store = STORE.lock().await;
|
||||||
|
let id = store.new_session(res).await;
|
||||||
|
|
||||||
|
depot.insert("session_id", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::debug!("Session was not provided in cookie");
|
||||||
|
let mut store = STORE.lock().await;
|
||||||
|
let id = store.new_session(res).await;
|
||||||
|
|
||||||
|
depot.insert("session_id", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn get_session(req: &mut Request, res: &mut Response, depot: &mut Depot) {
|
||||||
|
let store = STORE.lock().await;
|
||||||
|
|
||||||
|
let session_id = get_session_id(req, depot);
|
||||||
|
if session_id.is_none() {
|
||||||
|
res.status_code(StatusCode::BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match store.sessions.get(&session_id.unwrap()) {
|
||||||
|
Some(session) => {
|
||||||
|
res.render(Json(&session));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
res.status_code(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquires the session id from the request, preferring the depot
|
||||||
|
pub fn get_session_id(req: &Request, depot: &Depot) -> Option<u32> {
|
||||||
|
if depot.contains_key("session_id") {
|
||||||
|
return Some(*depot.get::<u32>("session_id").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, just use whatever the Cookie might have
|
||||||
|
match req.cookie("Session") {
|
||||||
|
Some(cookie) => cookie.value().parse::<u32>().ok(),
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Session was not provided in cookie or depot");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
backend/src/handlers/websocket.rs
Normal file
138
backend/src/handlers/websocket.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use futures_util::{FutureExt, StreamExt};
|
||||||
|
use salvo::http::StatusError;
|
||||||
|
use salvo::prelude::{handler, Request, Response, WebSocketUpgrade};
|
||||||
|
use salvo::websocket::WebSocket;
|
||||||
|
use salvo::Depot;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
|
|
||||||
|
use crate::models::{IncomingMessage, OutgoingMessage};
|
||||||
|
use crate::state::STORE;
|
||||||
|
|
||||||
|
use super::session::get_session_id;
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn connect(
|
||||||
|
req: &mut Request,
|
||||||
|
res: &mut Response,
|
||||||
|
depot: &Depot,
|
||||||
|
) -> Result<(), StatusError> {
|
||||||
|
let session_id = get_session_id(req, depot).unwrap();
|
||||||
|
WebSocketUpgrade::new()
|
||||||
|
.upgrade(req, res, move |ws| async move {
|
||||||
|
handle_socket(session_id, ws).await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(session_id: u32, websocket: WebSocket) {
|
||||||
|
// Split the socket into a sender and receive of messages.
|
||||||
|
let (socket_tx, mut socket_rx) = websocket.split();
|
||||||
|
|
||||||
|
// Use an unbounded channel to handle buffering and flushing of messages to the websocket...
|
||||||
|
let (tx_channel, tx_channel_rx) = mpsc::unbounded_channel();
|
||||||
|
let transmit = UnboundedReceiverStream::new(tx_channel_rx);
|
||||||
|
let fut_handle_tx_buffer = transmit
|
||||||
|
.then(|message| async {
|
||||||
|
match message {
|
||||||
|
Ok(ref message) => {
|
||||||
|
tracing::debug!(message = ?message, "Outgoing Message");
|
||||||
|
}
|
||||||
|
Err(ref e) => {
|
||||||
|
tracing::error!(error = ?e, "Outgoing Message Error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message
|
||||||
|
})
|
||||||
|
.forward(socket_tx)
|
||||||
|
.map(|result| {
|
||||||
|
tracing::debug!("WebSocket send result: {:?}", result);
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::error!(error = ?e, "websocket send error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokio::task::spawn(fut_handle_tx_buffer);
|
||||||
|
|
||||||
|
let store = &mut *STORE.lock().await;
|
||||||
|
|
||||||
|
// Create the executable message first, borrow issues
|
||||||
|
let executable_message = OutgoingMessage::Executables {
|
||||||
|
executables: store.executable_json(),
|
||||||
|
build_log: if store.build_logs.is_some() {
|
||||||
|
Some("/build-logs".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = store
|
||||||
|
.sessions
|
||||||
|
.get_mut(&session_id)
|
||||||
|
.expect("Unable to get session");
|
||||||
|
session.tx = Some(tx_channel);
|
||||||
|
|
||||||
|
session
|
||||||
|
.send_state()
|
||||||
|
.expect("Failed to buffer state message");
|
||||||
|
session
|
||||||
|
.send_message(executable_message)
|
||||||
|
.expect("Failed to buffer executables message");
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
let fut = async move {
|
||||||
|
tracing::info!(
|
||||||
|
"WebSocket connection established for session_id: {}",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
while let Some(result) = socket_rx.next().await {
|
||||||
|
let msg = match result {
|
||||||
|
Ok(msg) => msg,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(
|
||||||
|
"WebSocket Error session_id={} error=({})",
|
||||||
|
session_id,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if msg.is_close() {
|
||||||
|
tracing::info!("WebSocket closing for Session {}", session_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.is_text() {
|
||||||
|
let text = msg.to_str().unwrap();
|
||||||
|
|
||||||
|
// Deserialize
|
||||||
|
match serde_json::from_str::<IncomingMessage>(text) {
|
||||||
|
Ok(message) => {
|
||||||
|
tracing::debug!(message = ?message, "Received message");
|
||||||
|
|
||||||
|
match message {
|
||||||
|
IncomingMessage::DeleteDownloadToken { id } => {
|
||||||
|
let store = &mut *STORE.lock().await;
|
||||||
|
let session = store
|
||||||
|
.sessions
|
||||||
|
.get_mut(&session_id)
|
||||||
|
.expect("Session not found");
|
||||||
|
|
||||||
|
if session.delete_download(id) {
|
||||||
|
session
|
||||||
|
.send_state()
|
||||||
|
.expect("Failed to buffer state message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Error deserializing message: {} {}", text, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::task::spawn(fut);
|
||||||
|
}
|
||||||
6
backend/src/lib.rs
Normal file
6
backend/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
pub mod railway;
|
||||||
|
pub mod state;
|
||||||
129
backend/src/main.rs
Normal file
129
backend/src/main.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use dynamic_preauth::config::Config;
|
||||||
|
use dynamic_preauth::handlers::{
|
||||||
|
connect, download, get_build_logs, get_session, notify, session_middleware,
|
||||||
|
};
|
||||||
|
use dynamic_preauth::railway;
|
||||||
|
use dynamic_preauth::state::STORE;
|
||||||
|
|
||||||
|
use salvo::cors::Cors;
|
||||||
|
use salvo::http::Method;
|
||||||
|
use salvo::logging::Logger;
|
||||||
|
use salvo::prelude::{CatchPanic, Listener, Router, Server, Service, StaticDir, TcpListener};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Load environment variables from .env file (development only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
// Parse configuration from environment
|
||||||
|
let config: Config = envy::from_env().expect("Failed to parse environment configuration");
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::new(format!(
|
||||||
|
"info,dynamic_preauth={}",
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
"debug"
|
||||||
|
} else {
|
||||||
|
"info"
|
||||||
|
}
|
||||||
|
)))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Add the build log & executables to the store
|
||||||
|
let mut store = STORE.lock().await;
|
||||||
|
|
||||||
|
// Check if we are deployed on Railway
|
||||||
|
if config.railway.is_railway() {
|
||||||
|
if let Some(build_logs_url) = config.railway.build_logs_url() {
|
||||||
|
tracing::info!("Build logs available here: {}", build_logs_url);
|
||||||
|
store.build_log_url = Some(build_logs_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fetch actual build logs using Railway API
|
||||||
|
if config.railway.has_token() {
|
||||||
|
match railway::fetch_build_logs().await {
|
||||||
|
Ok(build_logs) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Successfully fetched build logs ({} bytes)",
|
||||||
|
build_logs.content.len()
|
||||||
|
);
|
||||||
|
store.build_logs = Some(build_logs);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to fetch build logs from Railway API: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("RAILWAY_TOKEN not set, skipping build log fetch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (exe_type, exe_path) in [
|
||||||
|
("Windows", "./demo.exe"),
|
||||||
|
("Linux", "./demo-linux"),
|
||||||
|
// ("MacOS", "./demo-macos"),
|
||||||
|
] {
|
||||||
|
if let Err(e) = store.add_executable(exe_type, exe_path) {
|
||||||
|
// In debug mode, allow missing Windows executable for dev convenience
|
||||||
|
if cfg!(debug_assertions) && exe_type == "Windows" {
|
||||||
|
tracing::warn!(
|
||||||
|
"Windows executable not found at {} (skipping - cross-compilation not set up)",
|
||||||
|
exe_path
|
||||||
|
);
|
||||||
|
tracing::warn!("To enable Windows builds: rustup target add x86_64-pc-windows-gnu && sudo apt install mingw-w64");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::error!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(store); // critical: Drop the lock to avoid deadlock, otherwise the server will hang
|
||||||
|
|
||||||
|
let origin = config.railway.cors_origin();
|
||||||
|
let cors = Cors::new()
|
||||||
|
.allow_origin(&origin)
|
||||||
|
.allow_methods(vec![Method::GET])
|
||||||
|
.into_handler();
|
||||||
|
tracing::debug!("CORS Allowed Origin: {}", &origin);
|
||||||
|
|
||||||
|
let static_dir = StaticDir::new(["./public"]).defaults("index.html");
|
||||||
|
|
||||||
|
// TODO: Improved Token Generation
|
||||||
|
// TODO: Advanced HMAC Verification
|
||||||
|
// TODO: Session Purging
|
||||||
|
|
||||||
|
let router = Router::new()
|
||||||
|
.hoop(CatchPanic::new())
|
||||||
|
// /notify does not need a session, nor should it have one
|
||||||
|
.push(Router::with_path("notify").post(notify))
|
||||||
|
// /build-logs does not need a session
|
||||||
|
.push(Router::with_path("build-logs").get(get_build_logs))
|
||||||
|
.push(
|
||||||
|
Router::new()
|
||||||
|
.hoop(session_middleware)
|
||||||
|
.push(Router::with_path("download/<id>").get(download))
|
||||||
|
.push(Router::with_path("session").get(get_session))
|
||||||
|
// websocket /ws
|
||||||
|
.push(Router::with_path("ws").goal(connect))
|
||||||
|
// static files
|
||||||
|
.push(Router::with_path("<**path>").get(static_dir)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let service = Service::new(router).hoop(cors).hoop(Logger::new());
|
||||||
|
|
||||||
|
let bind_addr = config.bind_addr();
|
||||||
|
tracing::info!("Server starting on http://{}", bind_addr);
|
||||||
|
tracing::info!("WebSocket endpoint: ws://{}/ws", bind_addr);
|
||||||
|
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
tracing::info!("Development mode - CORS allows all origins");
|
||||||
|
tracing::info!("Access the app at http://localhost:4321 (Astro dev server)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let acceptor = TcpListener::new(&bind_addr).bind().await;
|
||||||
|
Server::new(acceptor).serve(service).await;
|
||||||
|
}
|
||||||
6
backend/src/models/build_logs.rs
Normal file
6
backend/src/models/build_logs.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BuildLogs {
|
||||||
|
pub content: String,
|
||||||
|
pub fetched_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub content_hash: u64,
|
||||||
|
}
|
||||||
83
backend/src/models/executable.rs
Normal file
83
backend/src/models/executable.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug)]
|
||||||
|
pub struct Executable {
|
||||||
|
pub data: Vec<u8>, // the raw data of the executable
|
||||||
|
pub filename: String,
|
||||||
|
pub name: String, // the name before the extension
|
||||||
|
pub extension: String, // may be empty string
|
||||||
|
pub key_start: usize, // the index of the byte where the key starts
|
||||||
|
pub key_end: usize, // the index of the byte where the key ends
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Executable {
|
||||||
|
pub fn search_pattern(buf: &[u8], pattern: &[u8], start_index: usize) -> Option<usize> {
|
||||||
|
let mut i = start_index;
|
||||||
|
|
||||||
|
// If the buffer is empty, the pattern is too long
|
||||||
|
if pattern.len() > buf.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the pattern is empty
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the starting index is too high
|
||||||
|
if start_index >= buf.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
while i < buf.len() {
|
||||||
|
for j in 0..pattern.len() {
|
||||||
|
// If the pattern is too long to fit in the buffer anymore
|
||||||
|
if i + j >= buf.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the pattern stops matching
|
||||||
|
if buf[i + j] != pattern[j] {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the pattern is found
|
||||||
|
if j == pattern.len() - 1 {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_key(&self, new_key: &[u8]) -> Vec<u8> {
|
||||||
|
let mut data = self.data.clone();
|
||||||
|
|
||||||
|
// Copy the key into the data
|
||||||
|
for i in 0..new_key.len() {
|
||||||
|
data[self.key_start + i] = new_key[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the new key is shorter than the old key, we just write over the remaining data
|
||||||
|
if new_key.len() < self.key_end - self.key_start {
|
||||||
|
for item in data
|
||||||
|
.iter_mut()
|
||||||
|
.take(self.key_end)
|
||||||
|
.skip(self.key_start + new_key.len())
|
||||||
|
{
|
||||||
|
*item = b' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExecutableJson {
|
||||||
|
pub id: String,
|
||||||
|
pub size: usize,
|
||||||
|
pub filename: String,
|
||||||
|
}
|
||||||
29
backend/src/models/messages.rs
Normal file
29
backend/src/models/messages.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::executable::ExecutableJson;
|
||||||
|
use super::session::Session;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||||
|
pub enum IncomingMessage {
|
||||||
|
// A request from the client to delete a download token
|
||||||
|
DeleteDownloadToken { id: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||||
|
pub enum OutgoingMessage {
|
||||||
|
// An alert to the client that a session download has been used.
|
||||||
|
#[serde(rename = "notify")]
|
||||||
|
TokenAlert {
|
||||||
|
token: u32,
|
||||||
|
},
|
||||||
|
// A message describing the current session state
|
||||||
|
State {
|
||||||
|
session: Session,
|
||||||
|
},
|
||||||
|
Executables {
|
||||||
|
build_log: Option<String>,
|
||||||
|
executables: Vec<ExecutableJson>,
|
||||||
|
},
|
||||||
|
}
|
||||||
9
backend/src/models/mod.rs
Normal file
9
backend/src/models/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
mod build_logs;
|
||||||
|
mod executable;
|
||||||
|
mod messages;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
pub use build_logs::BuildLogs;
|
||||||
|
pub use executable::{Executable, ExecutableJson};
|
||||||
|
pub use messages::{IncomingMessage, OutgoingMessage};
|
||||||
|
pub use session::Session;
|
||||||
97
backend/src/models/session.rs
Normal file
97
backend/src/models/session.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
use salvo::websocket::Message;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
|
use super::executable::Executable;
|
||||||
|
use super::messages::OutgoingMessage;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: u32,
|
||||||
|
pub downloads: Vec<SessionDownload>,
|
||||||
|
|
||||||
|
pub first_seen: chrono::DateTime<chrono::Utc>,
|
||||||
|
// The last time a request OR websocket message from/to this session was made
|
||||||
|
pub last_seen: chrono::DateTime<chrono::Utc>,
|
||||||
|
// The last time a request was made with this session
|
||||||
|
pub last_request: chrono::DateTime<chrono::Utc>,
|
||||||
|
|
||||||
|
// The sender for the websocket connection
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub tx: Option<UnboundedSender<Result<Message, salvo::Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
// Update the last seen time(s) for the session
|
||||||
|
pub fn seen(&mut self, socket: bool) {
|
||||||
|
self.last_seen = chrono::Utc::now();
|
||||||
|
if !socket {
|
||||||
|
self.last_request = chrono::Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a download to the session
|
||||||
|
pub fn add_download(&mut self, exe: &Executable) -> &SessionDownload {
|
||||||
|
let token: u32 = rand::random();
|
||||||
|
|
||||||
|
let download = SessionDownload {
|
||||||
|
token,
|
||||||
|
filename: format!(
|
||||||
|
"{}-{:08x}{}{}",
|
||||||
|
exe.name,
|
||||||
|
token,
|
||||||
|
if !exe.extension.is_empty() { "." } else { "" },
|
||||||
|
exe.extension
|
||||||
|
),
|
||||||
|
last_used: chrono::Utc::now(),
|
||||||
|
download_time: chrono::Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.downloads.push(download);
|
||||||
|
self.downloads.last().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a download from the session
|
||||||
|
// Returns true if the download was deleted, false if it was not found
|
||||||
|
pub fn delete_download(&mut self, token: u32) -> bool {
|
||||||
|
if let Some(index) = self.downloads.iter().position(|d| d.token == token) {
|
||||||
|
self.downloads.remove(index);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Attempted to delete non-existent download token: {}", token);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function's failure is not a failure to transmit the message, but a failure to buffer it into the channel (or any preceding steps).
|
||||||
|
pub fn send_message(&mut self, message: OutgoingMessage) -> Result<(), anyhow::Error> {
|
||||||
|
if self.tx.is_none() {
|
||||||
|
return Err(anyhow::anyhow!("Session {} has no sender", self.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Error handling
|
||||||
|
let tx = self.tx.as_ref().unwrap();
|
||||||
|
let result = tx.send(Ok(Message::text(serde_json::to_string(&message).unwrap())));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => Err(anyhow::anyhow!("Error sending message: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_state(&mut self) -> Result<(), anyhow::Error> {
|
||||||
|
let message = OutgoingMessage::State {
|
||||||
|
session: self.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.send_message(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, Clone)]
|
||||||
|
pub struct SessionDownload {
|
||||||
|
pub token: u32,
|
||||||
|
pub filename: String,
|
||||||
|
pub last_used: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub download_time: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
276
backend/src/railway.rs
Normal file
276
backend/src/railway.rs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GraphQLRequest {
|
||||||
|
query: String,
|
||||||
|
variables: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GraphQLResponse {
|
||||||
|
data: Option<serde_json::Value>,
|
||||||
|
errors: Option<Vec<GraphQLError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GraphQLError {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BuildLogEntry {
|
||||||
|
message: String,
|
||||||
|
severity: String,
|
||||||
|
timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DeploymentNode {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DeploymentEdge {
|
||||||
|
node: DeploymentNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DeploymentsConnection {
|
||||||
|
edges: Vec<DeploymentEdge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_ansi_codes(text: &str) -> String {
|
||||||
|
// Simple regex to remove ANSI escape sequences
|
||||||
|
let re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
|
||||||
|
re.replace_all(text, "").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_stop_at_message(message: &str) -> bool {
|
||||||
|
let clean_message = strip_ansi_codes(message);
|
||||||
|
|
||||||
|
// Check for "Build time: X seconds" pattern (case insensitive)
|
||||||
|
let build_time_pattern = regex::Regex::new(r"(?i)Build\s+time:\s+\d+").unwrap();
|
||||||
|
if build_time_pattern.is_match(&clean_message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for "Starting Container" (case insensitive)
|
||||||
|
let starting_container_pattern = regex::Regex::new(r"(?i)Starting\s+Container").unwrap();
|
||||||
|
if starting_container_pattern.is_match(&clean_message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_latest_deployment_id() -> Result<String> {
|
||||||
|
let token = env::var("RAILWAY_TOKEN")?;
|
||||||
|
let service_id = env::var("RAILWAY_SERVICE_ID")?;
|
||||||
|
let project_id = env::var("RAILWAY_PROJECT_ID")?;
|
||||||
|
let environment_id = env::var("RAILWAY_ENVIRONMENT_ID")?;
|
||||||
|
|
||||||
|
let query = r#"
|
||||||
|
query deployments($input: DeploymentListInput!, $first: Int) {
|
||||||
|
deployments(input: $input, first: $first) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let variables = serde_json::json!({
|
||||||
|
"input": {
|
||||||
|
"projectId": project_id,
|
||||||
|
"serviceId": service_id,
|
||||||
|
"environmentId": environment_id,
|
||||||
|
"status": {"in": ["SUCCESS", "DEPLOYING", "SLEEPING", "BUILDING"]}
|
||||||
|
},
|
||||||
|
"first": 1
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = GraphQLRequest {
|
||||||
|
query: query.to_string(),
|
||||||
|
variables,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post("https://backboard.railway.app/graphql/v2")
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response_text = response.text().await?;
|
||||||
|
let graphql_response: GraphQLResponse = serde_json::from_str(&response_text)?;
|
||||||
|
|
||||||
|
if let Some(errors) = graphql_response.errors {
|
||||||
|
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"GraphQL errors: {}",
|
||||||
|
error_messages.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data) = graphql_response.data {
|
||||||
|
if let Some(deployments_value) = data.get("deployments") {
|
||||||
|
if let Ok(deployments) =
|
||||||
|
serde_json::from_value::<DeploymentsConnection>(deployments_value.clone())
|
||||||
|
{
|
||||||
|
if let Some(first_edge) = deployments.edges.first() {
|
||||||
|
return Ok(first_edge.node.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"No deployments found or unexpected response structure"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_build_logs() -> Result<crate::models::BuildLogs> {
|
||||||
|
let token = env::var("RAILWAY_TOKEN")?;
|
||||||
|
|
||||||
|
// Get deployment ID - in debug mode, fetch latest if not specified
|
||||||
|
let deployment_id = if cfg!(debug_assertions) {
|
||||||
|
match env::var("RAILWAY_DEPLOYMENT_ID") {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"No RAILWAY_DEPLOYMENT_ID specified in debug mode, fetching latest deployment"
|
||||||
|
);
|
||||||
|
fetch_latest_deployment_id().await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
env::var("RAILWAY_DEPLOYMENT_ID")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = r#"
|
||||||
|
query buildLogs($deploymentId: String!, $endDate: DateTime, $filter: String, $limit: Int, $startDate: DateTime) {
|
||||||
|
buildLogs(
|
||||||
|
deploymentId: $deploymentId
|
||||||
|
endDate: $endDate
|
||||||
|
filter: $filter
|
||||||
|
limit: $limit
|
||||||
|
startDate: $startDate
|
||||||
|
) {
|
||||||
|
message
|
||||||
|
severity
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let variables = serde_json::json!({
|
||||||
|
"deploymentId": deployment_id,
|
||||||
|
"limit": 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = GraphQLRequest {
|
||||||
|
query: query.to_string(),
|
||||||
|
variables,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post("https://backboard.railway.app/graphql/v2")
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response_text = response.text().await?;
|
||||||
|
let graphql_response: GraphQLResponse = serde_json::from_str(&response_text)?;
|
||||||
|
|
||||||
|
if let Some(errors) = graphql_response.errors {
|
||||||
|
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"GraphQL errors: {}",
|
||||||
|
error_messages.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data) = graphql_response.data {
|
||||||
|
if let Some(build_logs_value) = data.get("buildLogs") {
|
||||||
|
if let Ok(build_logs) =
|
||||||
|
serde_json::from_value::<Vec<BuildLogEntry>>(build_logs_value.clone())
|
||||||
|
{
|
||||||
|
let mut filtered_logs = Vec::new();
|
||||||
|
let starting_container_pattern =
|
||||||
|
regex::Regex::new(r"(?i)Starting\s+Container").unwrap();
|
||||||
|
|
||||||
|
for entry in build_logs {
|
||||||
|
// Check if we should stop at this message
|
||||||
|
if should_stop_at_message(&entry.message) {
|
||||||
|
// For "Build time" messages, include them
|
||||||
|
// For "Starting Container" messages, stop before them
|
||||||
|
let clean_message = strip_ansi_codes(&entry.message);
|
||||||
|
|
||||||
|
if starting_container_pattern.is_match(&clean_message) {
|
||||||
|
// Stop before "Starting Container" message
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
// Include "Build time" message and stop
|
||||||
|
let formatted_entry = format!(
|
||||||
|
"{} {} {}",
|
||||||
|
entry.timestamp,
|
||||||
|
entry.severity,
|
||||||
|
clean_message.trim()
|
||||||
|
);
|
||||||
|
filtered_logs.push(formatted_entry);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include this log entry
|
||||||
|
let clean_message = strip_ansi_codes(&entry.message);
|
||||||
|
let formatted_entry = format!(
|
||||||
|
"{} {} {}",
|
||||||
|
entry.timestamp,
|
||||||
|
entry.severity,
|
||||||
|
clean_message.trim()
|
||||||
|
);
|
||||||
|
filtered_logs.push(formatted_entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Railway URL header to the logs
|
||||||
|
let railway_url = format!(
|
||||||
|
"Railway Build Logs: https://railway.com/project/{}/service/{}?environmentId={}&id={}#build\n\n",
|
||||||
|
env::var("RAILWAY_PROJECT_ID").unwrap_or_default(),
|
||||||
|
env::var("RAILWAY_SERVICE_ID").unwrap_or_default(),
|
||||||
|
env::var("RAILWAY_ENVIRONMENT_ID").unwrap_or_default(),
|
||||||
|
deployment_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let content = format!("{}{}", railway_url, filtered_logs.join("\n"));
|
||||||
|
let fetched_at = chrono::Utc::now();
|
||||||
|
|
||||||
|
// Generate hash for the content
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
content.hash(&mut hasher);
|
||||||
|
let content_hash = hasher.finish();
|
||||||
|
|
||||||
|
return Ok(crate::models::BuildLogs {
|
||||||
|
content,
|
||||||
|
fetched_at,
|
||||||
|
content_hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Unexpected response structure from Railway API"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("No data received from Railway API"))
|
||||||
|
}
|
||||||
|
}
|
||||||
122
backend/src/state.rs
Normal file
122
backend/src/state.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use salvo::{http::cookie::Cookie, Response};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::errors::{AppError, Result};
|
||||||
|
use crate::models::{BuildLogs, Executable, ExecutableJson, Session};
|
||||||
|
|
||||||
|
pub static STORE: LazyLock<Mutex<State>> = LazyLock::new(|| Mutex::new(State::new()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct State {
|
||||||
|
pub sessions: HashMap<u32, Session>,
|
||||||
|
pub executables: HashMap<String, Executable>,
|
||||||
|
pub build_logs: Option<BuildLogs>,
|
||||||
|
pub build_log_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: HashMap::new(),
|
||||||
|
executables: HashMap::new(),
|
||||||
|
build_logs: None,
|
||||||
|
build_log_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_executable(&mut self, exe_type: &str, exe_path: &str) -> Result<()> {
|
||||||
|
let path = Path::new(exe_path);
|
||||||
|
|
||||||
|
let data = std::fs::read(path).map_err(|_| AppError::ExecutableNotFound {
|
||||||
|
path: PathBuf::from(exe_path),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let pattern = "a".repeat(1024);
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let key_start = Executable::search_pattern(&data, pattern.as_bytes(), 0)
|
||||||
|
.ok_or_else(|| AppError::KeyPatternNotFound { name: name.clone() })?;
|
||||||
|
let key_end = key_start + pattern.len();
|
||||||
|
|
||||||
|
let extension = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let exe = Executable {
|
||||||
|
data,
|
||||||
|
filename: path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
name,
|
||||||
|
extension,
|
||||||
|
key_start,
|
||||||
|
key_end,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.executables.insert(exe_type.to_string(), exe);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new_session(&mut self, res: &mut Response) -> u32 {
|
||||||
|
let id: u32 = rand::random();
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
self.sessions.insert(
|
||||||
|
id,
|
||||||
|
Session {
|
||||||
|
id,
|
||||||
|
downloads: Vec::new(),
|
||||||
|
last_seen: now,
|
||||||
|
last_request: now,
|
||||||
|
first_seen: now,
|
||||||
|
tx: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::info!("New session created: {}", id);
|
||||||
|
|
||||||
|
res.add_cookie(
|
||||||
|
Cookie::build(("Session", id.to_string()))
|
||||||
|
.http_only(true)
|
||||||
|
.partitioned(true)
|
||||||
|
.secure(cfg!(debug_assertions) == false)
|
||||||
|
.path("/")
|
||||||
|
// Use SameSite=None only in development
|
||||||
|
.same_site(if cfg!(debug_assertions) {
|
||||||
|
salvo::http::cookie::SameSite::None
|
||||||
|
} else {
|
||||||
|
salvo::http::cookie::SameSite::Strict
|
||||||
|
})
|
||||||
|
.permanent()
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn executable_json(&self) -> Vec<ExecutableJson> {
|
||||||
|
let mut executables = Vec::new();
|
||||||
|
|
||||||
|
for (key, exe) in &self.executables {
|
||||||
|
executables.push(ExecutableJson {
|
||||||
|
id: key.to_string(),
|
||||||
|
size: exe.data.len(),
|
||||||
|
filename: exe.filename.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
executables
|
||||||
|
}
|
||||||
|
}
|
||||||
36
bacon.toml
Normal file
36
bacon.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Bacon configuration for dynamic-preauth
|
||||||
|
|
||||||
|
default_job = "check"
|
||||||
|
|
||||||
|
[jobs.check]
|
||||||
|
command = ["cargo", "check", "--workspace", "--all-targets", "--all-features", "--color", "always"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
[jobs.clippy]
|
||||||
|
command = ["cargo", "clippy", "--workspace", "--all-targets", "--all-features", "--color", "always", "--", "-D", "warnings"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
[jobs.test]
|
||||||
|
command = ["cargo", "test", "--workspace", "--color", "always"]
|
||||||
|
need_stdout = true
|
||||||
|
|
||||||
|
[jobs.run]
|
||||||
|
command = ["cargo", "run", "--bin", "dynamic-preauth", "--color", "always"]
|
||||||
|
need_stdout = true
|
||||||
|
on_success = "back"
|
||||||
|
|
||||||
|
[jobs.doc]
|
||||||
|
command = ["cargo", "doc", "--workspace", "--all-features", "--no-deps", "--color", "always"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
[keybindings]
|
||||||
|
# Use 'c' to switch to check job
|
||||||
|
c = "job:check"
|
||||||
|
# Use 'l' to switch to clippy job
|
||||||
|
l = "job:clippy"
|
||||||
|
# Use 't' to switch to test job
|
||||||
|
t = "job:test"
|
||||||
|
# Use 'r' to switch to run job
|
||||||
|
r = "job:run"
|
||||||
|
# Use 'd' to switch to doc job
|
||||||
|
d = "job:doc"
|
||||||
1612
demo/Cargo.lock
generated
1612
demo/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,19 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "demo"
|
name = "demo"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = "0.4.39"
|
hex.workspace = true
|
||||||
hex = "0.4.3"
|
reqwest = { workspace = true, features = ["blocking", "json"] }
|
||||||
reqwest = { version = "0.12.9", features = ["blocking", "json"] }
|
serde.workspace = true
|
||||||
serde = { version = "1.0.216", features = ["derive"] }
|
serde_json.workspace = true
|
||||||
serde_json = "1.0.134"
|
sha2.workspace = true
|
||||||
sha2 = "0.10.8"
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
chrono = "0.4.39"
|
chrono.workspace = true
|
||||||
hex = "0.4.3"
|
hex.workspace = true
|
||||||
serde = { version = "1.0.216", features = ["derive"] }
|
serde.workspace = true
|
||||||
serde_json = "1.0.134"
|
serde_json.workspace = true
|
||||||
sha2 = "0.10.8"
|
sha2.workspace = true
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
strip = true
|
|
||||||
opt-level = "z"
|
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let json_data = serde_json::to_string(&key_data)?;
|
let json_data = serde_json::to_string(&key_data)?;
|
||||||
write!(f, "{}", json_data.to_string())?;
|
write!(f, "{}", json_data)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ struct KeyData<'a> {
|
|||||||
compile_time: String,
|
compile_time: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
static KEY: &'static str = include_str!(concat!(env!("OUT_DIR"), "/key.json"));
|
static KEY: &str = include_str!(concat!(env!("OUT_DIR"), "/key.json"));
|
||||||
const HOST_INFO: (&'static str, &'static str) = match option_env!("RAILWAY_PUBLIC_DOMAIN") {
|
const HOST_INFO: (&str, &str) = match option_env!("RAILWAY_PUBLIC_DOMAIN") {
|
||||||
Some(domain) => ("https", domain),
|
Some(domain) => ("https", domain),
|
||||||
None => ("http", "localhost:5800"),
|
None => ("http", "localhost:5800"),
|
||||||
};
|
};
|
||||||
@@ -55,7 +55,7 @@ fn main() {
|
|||||||
request(token);
|
request(token);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Token was changed, but is not a valid u32 integer: {}", e);
|
eprintln!("Token is not a valid u32 integer: {}", e);
|
||||||
eprintln!("Original Value: {}", key_data.value);
|
eprintln!("Original Value: {}", key_data.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ fn main() {
|
|||||||
fn request(token: u32) {
|
fn request(token: u32) {
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.post(&format!(
|
.post(format!(
|
||||||
"{}://{}/notify?key=0x{:08X}",
|
"{}://{}/notify?key=0x{:08X}",
|
||||||
HOST_INFO.0, HOST_INFO.1, token
|
HOST_INFO.0, HOST_INFO.1, token
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig, envField } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
import tailwind from "@astrojs/tailwind";
|
import tailwind from "@astrojs/tailwind";
|
||||||
import sitemap from "@astrojs/sitemap";
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ if (
|
|||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
outDir: "../public",
|
||||||
build: {
|
build: {
|
||||||
assets: "assets",
|
assets: "assets",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astrojs/check": "^0.9.6",
|
||||||
"@astrojs/react": "^4.1.2",
|
"@astrojs/react": "^4.1.2",
|
||||||
"@astrojs/sitemap": "^3.2.1",
|
"@astrojs/sitemap": "^3.2.1",
|
||||||
"@astrojs/tailwind": "^5.1.4",
|
"@astrojs/tailwind": "^5.1.4",
|
||||||
@@ -21,10 +22,12 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-tooltip": "^5.28.0",
|
"react-tooltip": "^5.28.0",
|
||||||
"react-use-websocket": "^4.11.1",
|
"react-use-websocket": "^4.11.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf",
|
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
627
frontend/pnpm-lock.yaml
generated
627
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,7 @@ const Demo = ({ class: className }: DemoProps) => {
|
|||||||
buildLog={buildLog}
|
buildLog={buildLog}
|
||||||
executables={executables}
|
executables={executables}
|
||||||
/>
|
/>
|
||||||
{downloads?.map((download, i) => (
|
{downloads?.map((download) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={download.token}
|
key={download.token}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Executable } from "@/components/useSocket";
|
import type { Executable } from "@/components/useSocket";
|
||||||
import { cn, withBackend } from "@/util";
|
import MobileWarningModal from "@/components/MobileWarningModal";
|
||||||
|
import { cn, isMobile, withBackend } from "@/util";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -9,11 +10,13 @@ import {
|
|||||||
MenuSeparator,
|
MenuSeparator,
|
||||||
} from "@headlessui/react";
|
} from "@headlessui/react";
|
||||||
import {
|
import {
|
||||||
ArrowDownTrayIcon,
|
|
||||||
BeakerIcon,
|
BeakerIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
} from "@heroicons/react/16/solid";
|
} from "@heroicons/react/16/solid";
|
||||||
import { useRef } from "react";
|
import { FaWindows, FaApple, FaLinux } from "react-icons/fa";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
const MOBILE_WARNING_KEY = "mobile-warning-acknowledged";
|
||||||
|
|
||||||
type DownloadButtonProps = {
|
type DownloadButtonProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -36,17 +39,78 @@ function getSystemType(): SystemType | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPlatformIcon(id: string, className?: string) {
|
||||||
|
const platformId = id.toLowerCase();
|
||||||
|
switch (platformId) {
|
||||||
|
case "windows":
|
||||||
|
return <FaWindows className={className} />;
|
||||||
|
case "macos":
|
||||||
|
return <FaApple className={className} />;
|
||||||
|
case "linux":
|
||||||
|
return <FaLinux className={className} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformDisplayName(id: string): string {
|
||||||
|
const platformId = id.toLowerCase();
|
||||||
|
switch (platformId) {
|
||||||
|
case "windows":
|
||||||
|
return "Windows";
|
||||||
|
case "macos":
|
||||||
|
return "macOS";
|
||||||
|
case "linux":
|
||||||
|
return "Linux";
|
||||||
|
default:
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function DownloadButton({
|
export default function DownloadButton({
|
||||||
disabled,
|
disabled,
|
||||||
executables,
|
executables,
|
||||||
buildLog,
|
buildLog,
|
||||||
}: DownloadButtonProps) {
|
}: DownloadButtonProps) {
|
||||||
const menuRef = useRef<HTMLButtonElement>(null);
|
const menuRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [showMobileWarning, setShowMobileWarning] = useState(false);
|
||||||
|
const [mobileAcknowledged, setMobileAcknowledged] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return sessionStorage.getItem(MOBILE_WARNING_KEY) === "true";
|
||||||
|
});
|
||||||
|
|
||||||
function getExecutable(id: string) {
|
function getExecutable(id: string) {
|
||||||
return executables?.find((e) => e.id.toLowerCase() === id.toLowerCase());
|
return executables?.find((e) => e.id.toLowerCase() === id.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mobile = isMobile();
|
||||||
|
const detectedPlatform = mobile ? null : getSystemType();
|
||||||
|
const platformExecutable = detectedPlatform ? getExecutable(detectedPlatform) : null;
|
||||||
|
const canAutoDownload = platformExecutable != null;
|
||||||
|
|
||||||
|
function acknowledgeMobileWarning() {
|
||||||
|
sessionStorage.setItem(MOBILE_WARNING_KEY, "true");
|
||||||
|
setMobileAcknowledged(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMobileButtonClick() {
|
||||||
|
if (!mobileAcknowledged) {
|
||||||
|
setShowMobileWarning(true);
|
||||||
|
} else {
|
||||||
|
menuRef.current?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMobileWarningClose() {
|
||||||
|
setShowMobileWarning(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMobileWarningContinue() {
|
||||||
|
acknowledgeMobileWarning();
|
||||||
|
setShowMobileWarning(false);
|
||||||
|
menuRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDownload(id: string) {
|
async function handleDownload(id: string) {
|
||||||
const executable = getExecutable(id);
|
const executable = getExecutable(id);
|
||||||
if (executable == null) {
|
if (executable == null) {
|
||||||
@@ -59,50 +123,71 @@ export default function DownloadButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDownloadAutomatic() {
|
function handleDownloadAutomatic() {
|
||||||
const systemType = getSystemType();
|
if (canAutoDownload && detectedPlatform) {
|
||||||
|
handleDownload(detectedPlatform);
|
||||||
// If the system type is unknown/unavailable, open the menu for manual selection
|
|
||||||
if (systemType == null || getExecutable(systemType) == null) {
|
|
||||||
menuRef.current?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, download the executable automatically
|
|
||||||
else {
|
|
||||||
handleDownload(systemType);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<MobileWarningModal
|
||||||
"[&>*]:py-1 overflow-clip transition-[background-color] text-sm/6 flex items-center shadow-inner align-middle text-white focus:outline-none data-[focus]:outline-1 data-[focus]:outline-white",
|
open={showMobileWarning}
|
||||||
!disabled
|
onClose={handleMobileWarningClose}
|
||||||
? "divide-white/[0.2] shadow-white/10 bg-emerald-800 data-[hover]:bg-emerald-700 data-[open]:bg-emerald-700"
|
onContinue={handleMobileWarningContinue}
|
||||||
: "divide-white/[0.1] shadow-white/5 animate-pulse-dark data-[hover]:bg-[#064e3b] cursor-wait",
|
/>
|
||||||
"rounded-md divide-x h-full rounded-l-md"
|
<div
|
||||||
)}
|
className={cn(
|
||||||
>
|
"[&>*]:py-1 overflow-clip transition-[background-color] text-sm/6 flex items-center shadow-inner align-middle text-white focus:outline-none data-[focus]:outline-1 data-[focus]:outline-white",
|
||||||
<Button
|
!disabled
|
||||||
onClick={handleDownloadAutomatic}
|
? "divide-white/[0.2] shadow-white/10 bg-emerald-800 data-[hover]:bg-emerald-700 data-[open]:bg-emerald-700"
|
||||||
suppressHydrationWarning
|
: "divide-white/[0.1] shadow-white/5 animate-pulse-dark data-[hover]:bg-[#064e3b] cursor-wait",
|
||||||
disabled={disabled}
|
"rounded-md divide-x h-full rounded-l-md"
|
||||||
className={cn("pl-3 font-semibold pr-2.5", {
|
)}
|
||||||
"hover:bg-white/5": !disabled,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
Download
|
<Button
|
||||||
</Button>
|
onClick={
|
||||||
<Menu>
|
mobile
|
||||||
<MenuButton
|
? handleMobileButtonClick
|
||||||
ref={menuRef}
|
: canAutoDownload
|
||||||
|
? handleDownloadAutomatic
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
disabled={disabled}
|
disabled={disabled || (!mobile && !canAutoDownload)}
|
||||||
className={cn("pl-1.5 text-transparent min-h-8 pr-2", {
|
className={cn("pl-3 font-semibold pr-2.5", {
|
||||||
"hover:bg-white/5": !disabled,
|
"hover:bg-white/5 cursor-pointer": !disabled && (mobile || canAutoDownload),
|
||||||
|
"cursor-default": !mobile && !canAutoDownload,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4 fill-white/60" />
|
{mobile
|
||||||
</MenuButton>
|
? "Download for Desktop"
|
||||||
|
: canAutoDownload && detectedPlatform
|
||||||
|
? `Download for ${getPlatformDisplayName(detectedPlatform)}`
|
||||||
|
: "Download"}
|
||||||
|
</Button>
|
||||||
|
<Menu>
|
||||||
|
{mobile && !mobileAcknowledged ? (
|
||||||
|
<button
|
||||||
|
onClick={handleMobileButtonClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("pl-1.5 min-h-8 pr-2 py-1", {
|
||||||
|
"hover:bg-white/5": !disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4 fill-white/60" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<MenuButton
|
||||||
|
ref={menuRef}
|
||||||
|
suppressHydrationWarning
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("pl-1.5 text-transparent min-h-8 pr-2", {
|
||||||
|
"hover:bg-white/5": !disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4 fill-white/60" />
|
||||||
|
</MenuButton>
|
||||||
|
)}
|
||||||
<MenuItems
|
<MenuItems
|
||||||
transition
|
transition
|
||||||
anchor="bottom end"
|
anchor="bottom end"
|
||||||
@@ -115,8 +200,8 @@ export default function DownloadButton({
|
|||||||
onClick={() => handleDownload(executable.id)}
|
onClick={() => handleDownload(executable.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<ArrowDownTrayIcon className="size-4 fill-white/40" />
|
{getPlatformIcon(executable.id, "size-4 fill-white/40")}
|
||||||
{executable.id}
|
{getPlatformDisplayName(executable.id)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-zinc-500">
|
||||||
{(executable.size / 1024 / 1024).toFixed(1)} MiB
|
{(executable.size / 1024 / 1024).toFixed(1)} MiB
|
||||||
@@ -130,7 +215,7 @@ export default function DownloadButton({
|
|||||||
<MenuItem>
|
<MenuItem>
|
||||||
<a
|
<a
|
||||||
className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-2 data-[focus]:bg-white/10"
|
className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-2 data-[focus]:bg-white/10"
|
||||||
href={buildLog}
|
href={buildLog.startsWith('/') ? withBackend(buildLog) : buildLog}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<BeakerIcon className="size-4 fill-white/40" />
|
<BeakerIcon className="size-4 fill-white/40" />
|
||||||
@@ -142,5 +227,6 @@ export default function DownloadButton({
|
|||||||
</MenuItems>
|
</MenuItems>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
57
frontend/src/components/MobileWarningModal.tsx
Normal file
57
frontend/src/components/MobileWarningModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBackdrop,
|
||||||
|
DialogPanel,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
type MobileWarningModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onContinue: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MobileWarningModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onContinue,
|
||||||
|
}: MobileWarningModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} className="relative z-50">
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 data-[closed]:opacity-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-900 p-5 shadow-xl transition-all duration-200 data-[closed]:scale-95 data-[closed]:opacity-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-lg font-semibold text-zinc-100">
|
||||||
|
Heads up!
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-zinc-300 leading-relaxed mb-4">
|
||||||
|
These downloads are desktop applications for Windows, macOS, and
|
||||||
|
Linux. They won't run on mobile devices, but you're welcome to
|
||||||
|
download them to transfer to a computer later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onContinue}
|
||||||
|
className="w-full rounded-lg bg-emerald-700 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-zinc-900"
|
||||||
|
>
|
||||||
|
Got it, continue
|
||||||
|
</button>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { withBackend } from "@/util";
|
import { withBackend } from "@/util";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||||
|
|
||||||
export interface Download {
|
export interface Download {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { WindowIcon } from "@heroicons/react/16/solid";
|
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
@@ -20,6 +19,11 @@ export function os(): Platform | "other" {
|
|||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMobile(): boolean {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
return /android|iphone|ipad|ipod|webos|blackberry|windows phone/.test(ua);
|
||||||
|
}
|
||||||
|
|
||||||
export function toHex(value: number): string {
|
export function toHex(value: number): string {
|
||||||
return "0x" + value.toString(16).toUpperCase();
|
return "0x" + value.toString(16).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|||||||
418
src/main.rs
418
src/main.rs
@@ -1,418 +0,0 @@
|
|||||||
use std::sync::LazyLock;
|
|
||||||
use std::{env, vec};
|
|
||||||
|
|
||||||
use futures_util::{FutureExt, StreamExt};
|
|
||||||
use models::{IncomingMessage, OutgoingMessage};
|
|
||||||
use salvo::cors::Cors;
|
|
||||||
use salvo::http::{HeaderValue, Method, StatusCode, StatusError};
|
|
||||||
use salvo::logging::Logger;
|
|
||||||
use salvo::prelude::{
|
|
||||||
handler, CatchPanic, Listener, Request, Response, Router, Server, Service, StaticDir,
|
|
||||||
TcpListener, WebSocketUpgrade,
|
|
||||||
};
|
|
||||||
use salvo::websocket::WebSocket;
|
|
||||||
use salvo::writing::Json;
|
|
||||||
use salvo::Depot;
|
|
||||||
use tokio::sync::{mpsc, Mutex};
|
|
||||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
|
|
||||||
use crate::models::State;
|
|
||||||
|
|
||||||
static STORE: LazyLock<Mutex<State>> = LazyLock::new(State::new);
|
|
||||||
|
|
||||||
mod models;
|
|
||||||
mod utility;
|
|
||||||
|
|
||||||
#[handler]
|
|
||||||
async fn session_middleware(req: &mut Request, res: &mut Response, depot: &mut Depot) {
|
|
||||||
match req.cookie("Session") {
|
|
||||||
Some(cookie) => {
|
|
||||||
// Check if the session exists
|
|
||||||
match cookie.value().parse::<u32>() {
|
|
||||||
Ok(session_id) => {
|
|
||||||
let mut store = STORE.lock().await;
|
|
||||||
if !store.sessions.contains_key(&session_id) {
|
|
||||||
let new_session_id = store.new_session(res).await;
|
|
||||||
depot.insert("session_id", new_session_id);
|
|
||||||
tracing::debug!(
|
|
||||||
existing_session_id = session_id,
|
|
||||||
new_session_id = new_session_id,
|
|
||||||
"Session provided in cookie, but does not exist"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
store.sessions.get_mut(&session_id).unwrap().seen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(parse_error) => {
|
|
||||||
tracing::debug!(
|
|
||||||
invalid_session_id = cookie.value(),
|
|
||||||
error = ?parse_error,
|
|
||||||
"Session provided in cookie, but is not a valid number"
|
|
||||||
);
|
|
||||||
let mut store = STORE.lock().await;
|
|
||||||
let id = store.new_session(res).await;
|
|
||||||
|
|
||||||
depot.insert("session_id", id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::debug!("Session was not provided in cookie");
|
|
||||||
let mut store = STORE.lock().await;
|
|
||||||
let id = store.new_session(res).await;
|
|
||||||
|
|
||||||
depot.insert("session_id", id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[handler]
|
|
||||||
async fn connect(req: &mut Request, res: &mut Response, depot: &Depot) -> Result<(), StatusError> {
|
|
||||||
let session_id = get_session_id(req, depot).unwrap();
|
|
||||||
WebSocketUpgrade::new()
|
|
||||||
.upgrade(req, res, move |ws| async move {
|
|
||||||
handle_socket(session_id, ws).await;
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_socket(session_id: u32, websocket: WebSocket) {
|
|
||||||
// Split the socket into a sender and receive of messages.
|
|
||||||
let (socket_tx, mut socket_rx) = websocket.split();
|
|
||||||
|
|
||||||
// Use an unbounded channel to handle buffering and flushing of messages to the websocket...
|
|
||||||
let (tx_channel, tx_channel_rx) = mpsc::unbounded_channel();
|
|
||||||
let transmit = UnboundedReceiverStream::new(tx_channel_rx);
|
|
||||||
let fut_handle_tx_buffer = transmit
|
|
||||||
.then(|message| async {
|
|
||||||
match message {
|
|
||||||
Ok(ref message) => {
|
|
||||||
tracing::debug!(message = ?message, "Outgoing Message");
|
|
||||||
}
|
|
||||||
Err(ref e) => {
|
|
||||||
tracing::error!(error = ?e, "Outgoing Message Error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message
|
|
||||||
})
|
|
||||||
.forward(socket_tx)
|
|
||||||
.map(|result| {
|
|
||||||
tracing::debug!("WebSocket send result: {:?}", result);
|
|
||||||
if let Err(e) = result {
|
|
||||||
tracing::error!(error = ?e, "websocket send error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
tokio::task::spawn(fut_handle_tx_buffer);
|
|
||||||
|
|
||||||
let store = &mut *STORE.lock().await;
|
|
||||||
|
|
||||||
// Create the executable message first, borrow issues
|
|
||||||
let executable_message = OutgoingMessage::Executables {
|
|
||||||
executables: store.executable_json(),
|
|
||||||
build_log: store.build_log.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let session = store
|
|
||||||
.sessions
|
|
||||||
.get_mut(&session_id)
|
|
||||||
.expect("Unable to get session");
|
|
||||||
session.tx = Some(tx_channel);
|
|
||||||
|
|
||||||
session
|
|
||||||
.send_state()
|
|
||||||
.expect("Failed to buffer state message");
|
|
||||||
session
|
|
||||||
.send_message(executable_message)
|
|
||||||
.expect("Failed to buffer executables message");
|
|
||||||
|
|
||||||
// Handle incoming messages
|
|
||||||
let fut = async move {
|
|
||||||
tracing::info!(
|
|
||||||
"WebSocket connection established for session_id: {}",
|
|
||||||
session_id
|
|
||||||
);
|
|
||||||
|
|
||||||
while let Some(result) = socket_rx.next().await {
|
|
||||||
let msg = match result {
|
|
||||||
Ok(msg) => msg,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(
|
|
||||||
"WebSocket Error session_id={} error=({})",
|
|
||||||
session_id,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if msg.is_close() {
|
|
||||||
tracing::info!("WebSocket closing for Session {}", session_id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.is_text() {
|
|
||||||
let text = msg.to_str().unwrap();
|
|
||||||
|
|
||||||
// Deserialize
|
|
||||||
match serde_json::from_str::<IncomingMessage>(text) {
|
|
||||||
Ok(message) => {
|
|
||||||
tracing::debug!(message = ?message, "Received message");
|
|
||||||
|
|
||||||
match message {
|
|
||||||
IncomingMessage::DeleteDownloadToken { id } => {
|
|
||||||
let store = &mut *STORE.lock().await;
|
|
||||||
let session = store
|
|
||||||
.sessions
|
|
||||||
.get_mut(&session_id)
|
|
||||||
.expect("Session not found");
|
|
||||||
|
|
||||||
if session.delete_download(id) {
|
|
||||||
session
|
|
||||||
.send_state()
|
|
||||||
.expect("Failed to buffer state message");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Error deserializing message: {} {}", text, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::task::spawn(fut);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[handler]
|
|
||||||
pub async fn download(req: &mut Request, res: &mut Response, depot: &mut Depot) {
|
|
||||||
let download_id = req
|
|
||||||
.param::<String>("id")
|
|
||||||
.expect("Download ID required to download file");
|
|
||||||
|
|
||||||
let session_id =
|
|
||||||
get_session_id(req, depot).expect("Session ID could not be found via request or depot");
|
|
||||||
|
|
||||||
let store = &mut *STORE.lock().await;
|
|
||||||
|
|
||||||
let session = store
|
|
||||||
.sessions
|
|
||||||
.get_mut(&session_id)
|
|
||||||
.expect("Session not found");
|
|
||||||
let executable = store
|
|
||||||
.executables
|
|
||||||
.get(&download_id as &str)
|
|
||||||
.expect("Executable not found");
|
|
||||||
|
|
||||||
// Create a download for the session
|
|
||||||
let session_download = session.add_download(executable);
|
|
||||||
tracing::info!(session_id, type = download_id, dl_token = session_download.token, "Download created");
|
|
||||||
let data = executable.with_key(session_download.token.to_string().as_bytes());
|
|
||||||
|
|
||||||
if let Err(e) = res.write_body(data) {
|
|
||||||
tracing::error!("Error writing body: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.headers.insert(
|
|
||||||
"Content-Disposition",
|
|
||||||
HeaderValue::from_str(
|
|
||||||
format!("attachment; filename=\"{}\"", session_download.filename).as_str(),
|
|
||||||
)
|
|
||||||
.expect("Unable to create header"),
|
|
||||||
);
|
|
||||||
res.headers.insert(
|
|
||||||
"Content-Type",
|
|
||||||
HeaderValue::from_static("application/octet-stream"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Don't try to send state if somehow the session has not connected
|
|
||||||
if session.tx.is_some() {
|
|
||||||
session
|
|
||||||
.send_state()
|
|
||||||
.expect("Failed to buffer state message");
|
|
||||||
} else {
|
|
||||||
tracing::warn!("Download being made without any connection websocket");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[handler]
|
|
||||||
pub async fn notify(req: &mut Request, res: &mut Response) {
|
|
||||||
let key = req.query::<String>("key");
|
|
||||||
|
|
||||||
if key.is_none() {
|
|
||||||
res.status_code(StatusCode::BAD_REQUEST);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = key.unwrap();
|
|
||||||
|
|
||||||
if !key.starts_with("0x") {
|
|
||||||
res.status_code(StatusCode::BAD_REQUEST);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse key into u32
|
|
||||||
let key = match u32::from_str_radix(key.trim_start_matches("0x"), 16) {
|
|
||||||
Ok(k) => k,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Error parsing key: {}", e);
|
|
||||||
res.status_code(StatusCode::BAD_REQUEST);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let store = &mut *STORE.lock().await;
|
|
||||||
|
|
||||||
let target_session = store
|
|
||||||
.sessions
|
|
||||||
.iter_mut()
|
|
||||||
.find(|(_, session)| session.downloads.iter().find(|d| d.token == key).is_some());
|
|
||||||
|
|
||||||
match target_session {
|
|
||||||
Some((_, session)) => {
|
|
||||||
let message = OutgoingMessage::TokenAlert { token: key };
|
|
||||||
|
|
||||||
if let Err(e) = session.send_message(message) {
|
|
||||||
tracing::warn!(
|
|
||||||
error = e.to_string(),
|
|
||||||
"Session did not have a receiving WebSocket available, notify ignored.",
|
|
||||||
);
|
|
||||||
res.status_code(StatusCode::NOT_MODIFIED);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.render("Notification sent");
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::warn!("Session not found for key while attempting notify: {}", key);
|
|
||||||
res.status_code(StatusCode::UNAUTHORIZED);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[handler]
|
|
||||||
pub async fn get_session(req: &mut Request, res: &mut Response, depot: &mut Depot) {
|
|
||||||
let store = STORE.lock().await;
|
|
||||||
|
|
||||||
let session_id = get_session_id(req, depot);
|
|
||||||
if session_id.is_none() {
|
|
||||||
res.status_code(StatusCode::BAD_REQUEST);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match store.sessions.get(&session_id.unwrap()) {
|
|
||||||
Some(session) => {
|
|
||||||
res.render(Json(&session));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
res.status_code(StatusCode::BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acquires the session id from the request, preferring the depot
|
|
||||||
fn get_session_id(req: &Request, depot: &Depot) -> Option<u32> {
|
|
||||||
if depot.contains_key("session_id") {
|
|
||||||
return Some(*depot.get::<u32>("session_id").unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, just use whatever the Cookie might have
|
|
||||||
match req.cookie("Session") {
|
|
||||||
Some(cookie) => match cookie.value().parse::<u32>() {
|
|
||||||
Ok(id) => Some(id),
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
tracing::warn!("Session was not provided in cookie or depot");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
let port = std::env::var("PORT").unwrap_or_else(|_| "5800".to_string());
|
|
||||||
let addr = format!("0.0.0.0:{}", port);
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(EnvFilter::new(format!(
|
|
||||||
"info,dynamic_preauth={}",
|
|
||||||
// Only log our message in debug mode
|
|
||||||
match cfg!(debug_assertions) {
|
|
||||||
true => "debug",
|
|
||||||
false => "info",
|
|
||||||
}
|
|
||||||
)))
|
|
||||||
.init();
|
|
||||||
|
|
||||||
// Add the build log & executables to the store
|
|
||||||
let mut store = STORE.lock().await;
|
|
||||||
|
|
||||||
// Check if we are deployed on Railway
|
|
||||||
let is_railway = env::var("RAILWAY_PROJECT_ID").is_ok();
|
|
||||||
if is_railway {
|
|
||||||
let build_logs = format!(
|
|
||||||
"https://railway.com/project/{}/service/{}?environmentId={}&id={}#build",
|
|
||||||
env::var("RAILWAY_PROJECT_ID").unwrap(),
|
|
||||||
env::var("RAILWAY_SERVICE_ID").unwrap(),
|
|
||||||
env::var("RAILWAY_ENVIRONMENT_ID").unwrap(),
|
|
||||||
env::var("RAILWAY_DEPLOYMENT_ID").unwrap()
|
|
||||||
);
|
|
||||||
|
|
||||||
tracing::info!("Build logs available here: {}", build_logs);
|
|
||||||
store.build_log = Some(build_logs);
|
|
||||||
}
|
|
||||||
|
|
||||||
store.add_executable("Windows", "./demo.exe");
|
|
||||||
store.add_executable("Linux", "./demo-linux");
|
|
||||||
// store.add_executable("MacOS", "./demo-macos");
|
|
||||||
|
|
||||||
drop(store); // critical: Drop the lock to avoid deadlock, otherwise the server will hang
|
|
||||||
|
|
||||||
// Allow all origins if: debug mode or RAILWAY_PUBLIC_DOMAIN is not set
|
|
||||||
let origin = if cfg!(debug_assertions) | env::var_os("RAILWAY_PUBLIC_DOMAIN").is_none() {
|
|
||||||
"*".to_string()
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"https://{}",
|
|
||||||
env::var_os("RAILWAY_PUBLIC_DOMAIN")
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let cors = Cors::new()
|
|
||||||
.allow_origin(&origin)
|
|
||||||
.allow_methods(vec![Method::GET])
|
|
||||||
.into_handler();
|
|
||||||
tracing::debug!("CORS Allowed Origin: {}", &origin);
|
|
||||||
|
|
||||||
let static_dir = StaticDir::new(["./public"]).defaults("index.html");
|
|
||||||
|
|
||||||
// TODO: Move handlers to a separate file
|
|
||||||
// TODO: Improved Token Generation
|
|
||||||
// TODO: Advanded HMAC Verification
|
|
||||||
// TODO: Session Purging
|
|
||||||
|
|
||||||
let router = Router::new()
|
|
||||||
.hoop(CatchPanic::new())
|
|
||||||
// /notify does not need a session, nor should it have one
|
|
||||||
.push(Router::with_path("notify").post(notify))
|
|
||||||
.push(
|
|
||||||
Router::new()
|
|
||||||
.hoop(session_middleware)
|
|
||||||
.push(Router::with_path("download/<id>").get(download))
|
|
||||||
.push(Router::with_path("session").get(get_session))
|
|
||||||
// websocket /ws
|
|
||||||
.push(Router::with_path("ws").goal(connect))
|
|
||||||
// static files
|
|
||||||
.push(Router::with_path("<**path>").get(static_dir)),
|
|
||||||
);
|
|
||||||
|
|
||||||
let service = Service::new(router).hoop(cors).hoop(Logger::new());
|
|
||||||
|
|
||||||
let acceptor = TcpListener::new(addr).bind().await;
|
|
||||||
Server::new(acceptor).serve(service).await;
|
|
||||||
}
|
|
||||||
258
src/models.rs
258
src/models.rs
@@ -1,258 +0,0 @@
|
|||||||
use salvo::{http::cookie::Cookie, websocket::Message, Response};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::{collections::HashMap, path};
|
|
||||||
use tokio::sync::{mpsc::UnboundedSender, Mutex};
|
|
||||||
|
|
||||||
use crate::utility::search;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
|
||||||
pub struct Session {
|
|
||||||
pub id: u32,
|
|
||||||
pub downloads: Vec<SessionDownload>,
|
|
||||||
|
|
||||||
pub first_seen: chrono::DateTime<chrono::Utc>,
|
|
||||||
// The last time a request OR websocket message from/to this session was made
|
|
||||||
pub last_seen: chrono::DateTime<chrono::Utc>,
|
|
||||||
// The last time a request was made with this session
|
|
||||||
pub last_request: chrono::DateTime<chrono::Utc>,
|
|
||||||
|
|
||||||
// The sender for the websocket connection
|
|
||||||
#[serde(skip_serializing)]
|
|
||||||
pub tx: Option<UnboundedSender<Result<Message, salvo::Error>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Session {
|
|
||||||
// Update the last seen time(s) for the session
|
|
||||||
pub fn seen(&mut self, socket: bool) {
|
|
||||||
self.last_seen = chrono::Utc::now();
|
|
||||||
if !socket {
|
|
||||||
self.last_request = chrono::Utc::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a download to the session
|
|
||||||
pub fn add_download(&mut self, exe: &Executable) -> &SessionDownload {
|
|
||||||
let token: u32 = rand::random();
|
|
||||||
|
|
||||||
let download = SessionDownload {
|
|
||||||
token,
|
|
||||||
filename: format!(
|
|
||||||
"{}-{:08x}{}{}",
|
|
||||||
exe.name,
|
|
||||||
token,
|
|
||||||
if exe.extension.len() > 0 { "." } else { "" },
|
|
||||||
exe.extension
|
|
||||||
),
|
|
||||||
last_used: chrono::Utc::now(),
|
|
||||||
download_time: chrono::Utc::now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.downloads.push(download);
|
|
||||||
return self.downloads.last().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete a download from the session
|
|
||||||
// Returns true if the download was deleted, false if it was not found
|
|
||||||
pub fn delete_download(&mut self, token: u32) -> bool {
|
|
||||||
if let Some(index) = self.downloads.iter().position(|d| d.token == token) {
|
|
||||||
self.downloads.remove(index);
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
tracing::warn!("Attempted to delete non-existent download token: {}", token);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This function's failure is not a failure to transmit the message, but a failure to buffer it into the channel (or any preceding steps).
|
|
||||||
pub fn send_message(&mut self, message: OutgoingMessage) -> Result<(), anyhow::Error> {
|
|
||||||
if self.tx.is_none() {
|
|
||||||
return Err(anyhow::anyhow!("Session {} has no sender", self.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Error handling
|
|
||||||
let tx = self.tx.as_ref().unwrap();
|
|
||||||
let result = tx.send(Ok(Message::text(serde_json::to_string(&message).unwrap())));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => return Ok(()),
|
|
||||||
Err(e) => return Err(anyhow::anyhow!("Error sending message: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_state(&mut self) -> Result<(), anyhow::Error> {
|
|
||||||
let message = OutgoingMessage::State {
|
|
||||||
session: self.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.send_message(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Debug, Clone)]
|
|
||||||
pub struct SessionDownload {
|
|
||||||
pub token: u32,
|
|
||||||
pub filename: String,
|
|
||||||
pub last_used: chrono::DateTime<chrono::Utc>,
|
|
||||||
pub download_time: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionDownload {}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct State<'a> {
|
|
||||||
// A map of executables, keyed by their type/platform
|
|
||||||
pub executables: HashMap<&'a str, Executable>,
|
|
||||||
// A map of sessions, keyed by their identifier (a random number)
|
|
||||||
pub sessions: HashMap<u32, Session>,
|
|
||||||
// Provided on startup, the URL to the build log of the current deployment
|
|
||||||
pub build_log: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> State<'a> {
|
|
||||||
pub fn new() -> Mutex<Self> {
|
|
||||||
Mutex::new(Self {
|
|
||||||
build_log: None,
|
|
||||||
executables: HashMap::new(),
|
|
||||||
sessions: HashMap::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_executable(&mut self, exe_type: &'a str, exe_path: &str) {
|
|
||||||
let data = std::fs::read(&exe_path).expect("Unable to read file");
|
|
||||||
|
|
||||||
let pattern = "a".repeat(1024);
|
|
||||||
let key_start = search(&data, pattern.as_bytes(), 0).unwrap();
|
|
||||||
let key_end = key_start + pattern.len();
|
|
||||||
|
|
||||||
let path = path::Path::new(&exe_path);
|
|
||||||
let name = path.file_stem().unwrap().to_str().unwrap();
|
|
||||||
let extension = match path.extension() {
|
|
||||||
Some(s) => s.to_str().unwrap(),
|
|
||||||
None => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
let exe = Executable {
|
|
||||||
data,
|
|
||||||
filename: path.file_name().unwrap().to_str().unwrap().to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
extension: extension.to_string(),
|
|
||||||
key_start: key_start,
|
|
||||||
key_end: key_end,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.executables.insert(exe_type, exe);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn new_session(&mut self, res: &mut Response) -> u32 {
|
|
||||||
let id: u32 = rand::random();
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
self.sessions.insert(
|
|
||||||
id,
|
|
||||||
Session {
|
|
||||||
id,
|
|
||||||
downloads: Vec::new(),
|
|
||||||
last_seen: now,
|
|
||||||
last_request: now,
|
|
||||||
first_seen: now,
|
|
||||||
tx: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
tracing::info!("New session created: {}", id);
|
|
||||||
|
|
||||||
res.add_cookie(
|
|
||||||
Cookie::build(("Session", id.to_string()))
|
|
||||||
.http_only(true)
|
|
||||||
.partitioned(true)
|
|
||||||
.secure(cfg!(debug_assertions) == false)
|
|
||||||
.path("/")
|
|
||||||
// Use SameSite=None only in development
|
|
||||||
.same_site(if cfg!(debug_assertions) {
|
|
||||||
salvo::http::cookie::SameSite::None
|
|
||||||
} else {
|
|
||||||
salvo::http::cookie::SameSite::Strict
|
|
||||||
})
|
|
||||||
.permanent()
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn executable_json(&self) -> Vec<ExecutableJson> {
|
|
||||||
let mut executables = Vec::new();
|
|
||||||
|
|
||||||
for (key, exe) in &self.executables {
|
|
||||||
executables.push(ExecutableJson {
|
|
||||||
id: key.to_string(),
|
|
||||||
size: exe.data.len(),
|
|
||||||
filename: exe.filename.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return executables;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Clone, Debug)]
|
|
||||||
pub struct Executable {
|
|
||||||
pub data: Vec<u8>, // the raw data of the executable
|
|
||||||
pub filename: String,
|
|
||||||
pub name: String, // the name before the extension
|
|
||||||
pub extension: String, // may be empty string
|
|
||||||
pub key_start: usize, // the index of the byte where the key starts
|
|
||||||
pub key_end: usize, // the index of the byte where the key ends
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Executable {
|
|
||||||
pub fn with_key(&self, new_key: &[u8]) -> Vec<u8> {
|
|
||||||
let mut data = self.data.clone();
|
|
||||||
|
|
||||||
// Copy the key into the data
|
|
||||||
for i in 0..new_key.len() {
|
|
||||||
data[self.key_start + i] = new_key[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the new key is shorter than the old key, we just write over the remaining data
|
|
||||||
if new_key.len() < self.key_end - self.key_start {
|
|
||||||
for i in self.key_start + new_key.len()..self.key_end {
|
|
||||||
data[i] = b' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
|
||||||
pub enum IncomingMessage {
|
|
||||||
// A request from the client to delete a download token
|
|
||||||
DeleteDownloadToken { id: u32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
|
||||||
pub enum OutgoingMessage {
|
|
||||||
// An alert to the client that a session download has been used.
|
|
||||||
#[serde(rename = "notify")]
|
|
||||||
TokenAlert {
|
|
||||||
token: u32,
|
|
||||||
},
|
|
||||||
// A message describing the current session state
|
|
||||||
State {
|
|
||||||
session: Session,
|
|
||||||
},
|
|
||||||
Executables {
|
|
||||||
build_log: Option<String>,
|
|
||||||
executables: Vec<ExecutableJson>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ExecutableJson {
|
|
||||||
pub id: String,
|
|
||||||
pub size: usize,
|
|
||||||
pub filename: String,
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
pub(crate) fn search(buf: &[u8], pattern: &[u8], start_index: usize) -> Option<usize> {
|
|
||||||
let mut i = start_index;
|
|
||||||
|
|
||||||
// If the buffer is empty, the pattern is too long
|
|
||||||
if pattern.len() > buf.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the pattern is empty
|
|
||||||
if pattern.len() == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the starting index is too high
|
|
||||||
if start_index >= buf.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
while i < buf.len() {
|
|
||||||
for j in 0..pattern.len() {
|
|
||||||
// If the pattern is too long to fit in the buffer anymore
|
|
||||||
if i + j >= buf.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the pattern stops matching
|
|
||||||
if buf[i + j] != pattern[j] {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the pattern is found
|
|
||||||
if j == pattern.len() - 1 {
|
|
||||||
return Some(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user