mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-07-15 14:06:00 +00:00
✨ feat: rewrite in Go
This commit is contained in:
+1
-1
@@ -13,4 +13,4 @@ docs
|
||||
data
|
||||
frames
|
||||
.vscode
|
||||
video.mp4
|
||||
TrollSSH
|
||||
|
||||
+25
-13
@@ -1,35 +1,47 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=22
|
||||
|
||||
# Source video (auto-discovers video.* if unset). Only used to build frames.json.
|
||||
VIDEO_PATH=video.mp4
|
||||
# Generation settings
|
||||
# Stored frame resolution in pixels. Higher = sharper but bigger .tsf files.
|
||||
# regenerate your frames after changing it.
|
||||
FRAME_RESOLUTION=512
|
||||
|
||||
# Playback settings.
|
||||
# Playthroughs before the session is closed (0, unlimited).
|
||||
MAX_LOOP=5
|
||||
# Weather to loop the video or play random frames after completion. Options: loop | random
|
||||
# Whether to keep looping the same frame set or pick a random one after each
|
||||
# playthrough. Options: loop | random
|
||||
PLAYBACK_MODE=random
|
||||
# Let viewers switch frame sets with arrow keys
|
||||
# Let viewers switch frame sets with arrow keys.
|
||||
ALLOW_USER_CONTROL=true
|
||||
# Min ms between arrow-key switches; ignores faster repeats.
|
||||
SWITCH_DEBOUNCE_MS=120
|
||||
# Minimum ms between arrow-key switches
|
||||
SWITCH_DEBOUNCE_MS=500
|
||||
# Pause in ms after the fake login before playback starts.
|
||||
LOGIN_DELAY=1500
|
||||
|
||||
# ASCII rendering (live, no frame rebuild).
|
||||
# CHARSET: detailed|standard|simple|blocks or custom ramp.
|
||||
BRIGHTNESS_THRESHOLD=40
|
||||
# ASCII rendering (applied live)
|
||||
# CHARSET: detailed | standard | simple | blocks, or a custom ramp string.
|
||||
CHARSET=blocks
|
||||
# Pixels darker than this (0-100) render as the darkest ramp character.
|
||||
BRIGHTNESS_THRESHOLD=40
|
||||
# Invert the brightness ramp (for light terminal backgrounds).
|
||||
INVERT=false
|
||||
|
||||
# Max rendered width/height in characters.
|
||||
MAX_DIMENSION=1080
|
||||
# Stored frame resolution (px).
|
||||
# Higher = sharper but bigger frames.json. Rebuild required.
|
||||
FRAME_RESOLUTION=512
|
||||
|
||||
# Connection limits. New connections over a limit are dropped immediately.
|
||||
# Max simultaneous connections from a single client IP.
|
||||
MAX_CONNECTIONS=10
|
||||
# Max simultaneous connections across all clients combined.
|
||||
MAX_TOTAL_CONNECTIONS=1000
|
||||
MAX_AUTH_ATTEMPTS=6
|
||||
# Login attempts allowed per connection before it is disconnected.
|
||||
# (Empty password counts as an attempt.)
|
||||
MAX_AUTH_ATTEMPTS=3
|
||||
# SSH handshake deadline in ms (0 to disable).
|
||||
HANDSHAKE_TIMEOUT=10000
|
||||
|
||||
# Log attempted usernames/passwords.
|
||||
LOG_CREDENTIALS=true
|
||||
# Minimum log level to emit: debug | info | warn | error
|
||||
LOG_LEVEL=info
|
||||
|
||||
+30
-42
@@ -12,33 +12,30 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
bun-version: ["1.2"]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
bun-version: ${{ matrix.bun-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Format check
|
||||
run: bun run format
|
||||
run: test -z "$(gofmt -l src)"
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./src/
|
||||
|
||||
- name: Staticcheck
|
||||
uses: dominikh/staticcheck-action@v1
|
||||
with:
|
||||
install-go: false
|
||||
|
||||
- name: Build
|
||||
run: go build -o trollssh ./src
|
||||
|
||||
- name: Unit tests
|
||||
run: bun test
|
||||
run: go test -race ./src/
|
||||
|
||||
docker:
|
||||
needs: test
|
||||
@@ -48,16 +45,6 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: trollssh:ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Determine push conditions
|
||||
id: should_push
|
||||
run: |
|
||||
@@ -67,15 +54,15 @@ jobs:
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Determine image tag
|
||||
if: steps.should_push.outputs.push == 'true'
|
||||
id: tag
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/yuzuzensai/trollssh
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern=v{{version}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.should_push.outputs.push == 'true'
|
||||
@@ -85,12 +72,13 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push image
|
||||
if: steps.should_push.outputs.push == 'true'
|
||||
- name: Build (and push) image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/yuzuzensai/trollssh:${{ steps.tag.outputs.tag }}
|
||||
push: ${{ steps.should_push.outputs.push == 'true' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
+5
-1
@@ -138,4 +138,8 @@ dist
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
.pnp.*
|
||||
# Go build output
|
||||
/TrollSSH
|
||||
TrollSSH
|
||||
*.exe
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
dist/
|
||||
config/
|
||||
node_modules/
|
||||
bun.lock
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
+13
-12
@@ -1,14 +1,15 @@
|
||||
FROM oven/bun:1.2-slim
|
||||
WORKDIR /home/bun/app
|
||||
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile --production
|
||||
|
||||
COPY tsconfig.json ./
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY src ./src
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /trollssh ./src
|
||||
|
||||
CMD ["bun", "src/index.ts"]
|
||||
FROM alpine:3.22
|
||||
WORKDIR /home/app
|
||||
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
COPY --from=build /trollssh /usr/local/bin/trollssh
|
||||
|
||||
CMD ["trollssh"]
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "trollssh",
|
||||
"dependencies": {
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"sharp": "^0.35.3",
|
||||
"ssh2": "^1.17.0",
|
||||
"sshpk": "^1.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/sshpk": "^1.17.5",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
|
||||
|
||||
"@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||
|
||||
"@types/sharp": ["@types/sharp@0.31.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag=="],
|
||||
|
||||
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
|
||||
|
||||
"@types/sshpk": ["@types/sshpk@1.17.5", "", { "dependencies": { "@types/asn1": "*", "@types/node": "*" } }, "sha512-khJbOFAOnyLaqOoGIlDCF2ZghIb0jM1K1N0rRoKL0ObqUMwfkicu5zlJ5Wsp+Xn2EUduVwONXwsgbGk+COaCug=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.63.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/type-utils": "8.63.0", "@typescript-eslint/utils": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.63.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="],
|
||||
|
||||
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||
|
||||
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
|
||||
|
||||
"async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||
|
||||
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="],
|
||||
|
||||
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="],
|
||||
|
||||
"getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
|
||||
|
||||
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.63.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@types/asn1/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@types/fluent-ffmpeg/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@types/sharp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@types/sshpk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"@types/asn1/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"@types/sharp/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"@types/sshpk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -9,6 +9,6 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
# Host keys + banner/fakelogin/goodbye text assets (persisted).
|
||||
- ./data:/home/bun/app/data
|
||||
- ./data:/home/app/data
|
||||
# Pre-generated frame sets
|
||||
- ./frames:/home/bun/app/frames
|
||||
- ./frames:/home/app/frames
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// @ts-check
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/**", "config/**", "node_modules/**"],
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
module github.com/YuzuZensai/TrollSSH
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.47.0 // indirect
|
||||
@@ -0,0 +1,10 @@
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "trollssh",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/YuzuZensai/TrollSSH.git",
|
||||
"author": "Yuzu <yuzu@kirameki.cafe>",
|
||||
"license": "GPL-3.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"test": "bun test",
|
||||
"docker-build": "docker build . -t ghcr.io/yuzuzensai/trollssh:latest",
|
||||
"docker-push": "docker push ghcr.io/yuzuzensai/trollssh:latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/sshpk": "^1.17.5",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.63.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"sharp": "^0.35.3",
|
||||
"ssh2": "^1.17.0",
|
||||
"sshpk": "^1.18.0"
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTSFRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.tsf")
|
||||
original := &FramesContainer{
|
||||
Frames: [][]byte{{0, 1, 255}, {10, 20, 30}},
|
||||
FPS: 29.97,
|
||||
}
|
||||
if err := writeTSF(path, original); err != nil {
|
||||
t.Fatalf("writeTSF: %v", err)
|
||||
}
|
||||
fc, err := loadTSF(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadTSF: %v", err)
|
||||
}
|
||||
if fc.FPS != 29.97 {
|
||||
t.Errorf("fps = %v", fc.FPS)
|
||||
}
|
||||
if len(fc.Frames) != 2 {
|
||||
t.Fatalf("frames = %d", len(fc.Frames))
|
||||
}
|
||||
if string(fc.Frames[0]) != string([]byte{0, 1, 255}) {
|
||||
t.Errorf("frame0 = %v", fc.Frames[0])
|
||||
}
|
||||
if string(fc.Frames[1]) != string([]byte{10, 20, 30}) {
|
||||
t.Errorf("frame1 = %v", fc.Frames[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSFInvalid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.tsf")
|
||||
|
||||
os.WriteFile(path, []byte("not a tsf file"), 0o644)
|
||||
if _, err := loadTSF(path); err == nil {
|
||||
t.Error("expected error for garbage input")
|
||||
}
|
||||
|
||||
// Valid container but no frames.
|
||||
writeTSF(path, &FramesContainer{FPS: 30})
|
||||
if _, err := loadTSF(path); err == nil {
|
||||
t.Error("expected error for empty frames")
|
||||
}
|
||||
|
||||
// Valid container but fps <= 0.
|
||||
writeTSF(path, &FramesContainer{Frames: [][]byte{{1}}, FPS: 0})
|
||||
if _, err := loadTSF(path); err == nil {
|
||||
t.Error("expected error for fps<=0")
|
||||
}
|
||||
|
||||
// Truncated payload.
|
||||
writeTSF(path, &FramesContainer{Frames: [][]byte{{1, 2, 3, 4}}, FPS: 30})
|
||||
raw, _ := os.ReadFile(path)
|
||||
os.WriteFile(path, raw[:len(raw)-2], 0o644)
|
||||
if _, err := loadTSF(path); err == nil {
|
||||
t.Error("expected error for truncated file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
t.Setenv("HOST", "")
|
||||
t.Setenv("PORT", "")
|
||||
t.Setenv("PLAYBACK_MODE", "")
|
||||
t.Setenv("LOGIN_DELAY", "")
|
||||
cfg := loadConfig()
|
||||
if cfg.Host != "0.0.0.0" {
|
||||
t.Errorf("host = %q", cfg.Host)
|
||||
}
|
||||
if cfg.Port != 22 {
|
||||
t.Errorf("port = %d", cfg.Port)
|
||||
}
|
||||
if cfg.PlaybackMode != PlaybackLoop {
|
||||
t.Errorf("playbackMode = %q", cfg.PlaybackMode)
|
||||
}
|
||||
if cfg.Charset != "detailed" {
|
||||
t.Errorf("charset = %q", cfg.Charset)
|
||||
}
|
||||
if cfg.LoginDelay != 1500*time.Millisecond {
|
||||
t.Errorf("loginDelay = %v", cfg.LoginDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigClamping(t *testing.T) {
|
||||
t.Setenv("PORT", "999999")
|
||||
t.Setenv("BRIGHTNESS_THRESHOLD", "-5")
|
||||
cfg := loadConfig()
|
||||
if cfg.Port != 65535 {
|
||||
t.Errorf("port clamp = %d", cfg.Port)
|
||||
}
|
||||
if cfg.BrightnessThreshold != 0 {
|
||||
t.Errorf("brightness clamp = %d", cfg.BrightnessThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigInvalidFallsBack(t *testing.T) {
|
||||
t.Setenv("PORT", "not-a-number")
|
||||
t.Setenv("INVERT", "yes-please")
|
||||
t.Setenv("PLAYBACK_MODE", "shuffle")
|
||||
cfg := loadConfig()
|
||||
if cfg.Port != 22 {
|
||||
t.Errorf("port = %d, want default 22", cfg.Port)
|
||||
}
|
||||
if cfg.Invert {
|
||||
t.Error("invert should fall back to false")
|
||||
}
|
||||
if cfg.PlaybackMode != PlaybackLoop {
|
||||
t.Errorf("playbackMode = %q, want default loop", cfg.PlaybackMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvDurationMs(t *testing.T) {
|
||||
t.Setenv("D", "250")
|
||||
if got := envDurationMs("D", time.Second); got != 250*time.Millisecond {
|
||||
t.Errorf("250 = %v, want 250ms", got)
|
||||
}
|
||||
t.Setenv("D", "-10")
|
||||
if got := envDurationMs("D", time.Second); got != 0 {
|
||||
t.Errorf("negative = %v, want 0", got)
|
||||
}
|
||||
t.Setenv("D", "banana")
|
||||
if got := envDurationMs("D", time.Second); got != time.Second {
|
||||
t.Errorf("invalid = %v, want fallback 1s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackModeRandom(t *testing.T) {
|
||||
t.Setenv("PLAYBACK_MODE", "RaNdOm")
|
||||
if loadConfig().PlaybackMode != PlaybackRandom {
|
||||
t.Error("expected random")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCharset(t *testing.T) {
|
||||
if got := resolveCharset("blocks"); got != " ░▒▓█" {
|
||||
t.Errorf("blocks preset = %q", got)
|
||||
}
|
||||
if got := resolveCharset("XYZ"); got != "XYZ" {
|
||||
t.Errorf("custom ramp = %q", got)
|
||||
}
|
||||
if got := resolveCharset(""); !strings.HasPrefix(got, " .") {
|
||||
t.Errorf("default = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameToAscii(t *testing.T) {
|
||||
// Below threshold -> first ramp char; full brightness -> last.
|
||||
opts := asciiOptions{brightnessThreshold: 40, charset: "standard"}
|
||||
ramp := []rune(resolveCharset("standard"))
|
||||
out := []rune(frameToAscii([]byte{0, 255}, opts))
|
||||
if out[0] != ramp[0] {
|
||||
t.Errorf("dark px = %q, want %q", out[0], ramp[0])
|
||||
}
|
||||
if out[1] != ramp[len(ramp)-1] {
|
||||
t.Errorf("bright px = %q, want %q", out[1], ramp[len(ramp)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameToAsciiInvert(t *testing.T) {
|
||||
opts := asciiOptions{brightnessThreshold: 40, charset: "standard", invert: true}
|
||||
ramp := []rune(resolveCharset("standard"))
|
||||
out := []rune(frameToAscii([]byte{255}, opts))
|
||||
if out[0] != ramp[0] {
|
||||
t.Errorf("inverted bright = %q, want %q", out[0], ramp[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTracker(t *testing.T) {
|
||||
tr := newConnectionTracker()
|
||||
tr.increment("1.2.3.4")
|
||||
tr.increment("1.2.3.4")
|
||||
if !tr.hasReachedLimits("1.2.3.4", 2, 100) {
|
||||
t.Error("expected per-ip limit reached")
|
||||
}
|
||||
tr.decrement("1.2.3.4")
|
||||
tr.decrement("1.2.3.4")
|
||||
if tr.totalCount() != 0 {
|
||||
t.Errorf("total = %d", tr.totalCount())
|
||||
}
|
||||
if tr.hasReachedLimits("1.2.3.4", 2, 100) {
|
||||
t.Error("should be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampDimension(t *testing.T) {
|
||||
if clampDimension(0, 100) != 1 {
|
||||
t.Error("floor")
|
||||
}
|
||||
if clampDimension(500, 100) != 100 {
|
||||
t.Error("ceil")
|
||||
}
|
||||
if clampDimension(50, 100) != 50 {
|
||||
t.Error("passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDimsPtyReq(t *testing.T) {
|
||||
// "xterm" + cols=100 rows=40 + widthpx + heightpx
|
||||
payload := []byte{
|
||||
0, 0, 0, 5, 'x', 't', 'e', 'r', 'm',
|
||||
0, 0, 0, 100,
|
||||
0, 0, 0, 40,
|
||||
0, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
}
|
||||
cols, rows, ok := parseDims(payload)
|
||||
if !ok || cols != 100 || rows != 40 {
|
||||
t.Errorf("parseDims = %d,%d,%v", cols, rows, ok)
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PlaybackMode string
|
||||
|
||||
const (
|
||||
PlaybackLoop PlaybackMode = "loop"
|
||||
PlaybackRandom PlaybackMode = "random"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
MaxLoop int
|
||||
PlaybackMode PlaybackMode
|
||||
AllowUserControl bool
|
||||
SwitchDebounce time.Duration
|
||||
LoginDelay time.Duration
|
||||
MaxConnections int
|
||||
MaxTotalConnections int
|
||||
MaxAuthAttempts int
|
||||
HandshakeTimeout time.Duration
|
||||
MaxDimension int
|
||||
FrameResolution int
|
||||
BrightnessThreshold int
|
||||
Charset string
|
||||
Invert bool
|
||||
LogCredentials bool
|
||||
}
|
||||
|
||||
func warnInvalid(name, value string, fallback any) {
|
||||
logWarn(fmt.Sprintf("Invalid %s=%q, using default %v", name, sanitize(value), fallback))
|
||||
}
|
||||
|
||||
func envString(name, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envInt(name string, fallback, min, max int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
warnInvalid(name, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
if parsed < min {
|
||||
return min
|
||||
}
|
||||
if parsed > max {
|
||||
return max
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(strings.ToLower(raw))
|
||||
if err != nil {
|
||||
warnInvalid(name, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envDurationMs(name string, fallback time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
ms, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
warnInvalid(name, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
if ms < 0 {
|
||||
ms = 0
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
func envPlaybackMode(name string, fallback PlaybackMode) PlaybackMode {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
switch PlaybackMode(strings.ToLower(raw)) {
|
||||
case PlaybackLoop:
|
||||
return PlaybackLoop
|
||||
case PlaybackRandom:
|
||||
return PlaybackRandom
|
||||
}
|
||||
warnInvalid(name, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
const maxInt = int(^uint(0) >> 1)
|
||||
return Config{
|
||||
Host: envString("HOST", "0.0.0.0"),
|
||||
Port: envInt("PORT", 22, 1, 65535),
|
||||
MaxLoop: envInt("MAX_LOOP", 5, 0, maxInt),
|
||||
PlaybackMode: envPlaybackMode("PLAYBACK_MODE", PlaybackLoop),
|
||||
AllowUserControl: envBool("ALLOW_USER_CONTROL", true),
|
||||
SwitchDebounce: envDurationMs("SWITCH_DEBOUNCE_MS", 120*time.Millisecond),
|
||||
LoginDelay: envDurationMs("LOGIN_DELAY", 1500*time.Millisecond),
|
||||
MaxConnections: envInt("MAX_CONNECTIONS", 10, 1, maxInt),
|
||||
MaxTotalConnections: envInt("MAX_TOTAL_CONNECTIONS", 1000, 1, maxInt),
|
||||
MaxAuthAttempts: envInt("MAX_AUTH_ATTEMPTS", 6, 1, maxInt),
|
||||
HandshakeTimeout: envDurationMs("HANDSHAKE_TIMEOUT", 10*time.Second),
|
||||
MaxDimension: envInt("MAX_DIMENSION", 512, 1, 4096),
|
||||
FrameResolution: envInt("FRAME_RESOLUTION", 360, 16, 1080),
|
||||
BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100),
|
||||
Charset: envString("CHARSET", "detailed"),
|
||||
Invert: envBool("INVERT", false),
|
||||
LogCredentials: envBool("LOG_CREDENTIALS", false),
|
||||
}
|
||||
}
|
||||
|
||||
func loadOptionalTextFile(filePath string) (string, bool) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(data), true
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { loadConfig } from "./config";
|
||||
|
||||
describe("loadConfig", () => {
|
||||
test("returns defaults when env vars are unset", () => {
|
||||
expect(loadConfig({})).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: 22,
|
||||
maxLoop: 5,
|
||||
playbackMode: "loop",
|
||||
allowUserControl: true,
|
||||
switchDebounceMs: 120,
|
||||
loginDelay: 1500,
|
||||
maxConnections: 10,
|
||||
maxTotalConnections: 1000,
|
||||
maxAuthAttempts: 6,
|
||||
handshakeTimeout: 10000,
|
||||
maxDimension: 512,
|
||||
frameResolution: 360,
|
||||
brightnessThreshold: 40,
|
||||
charset: "detailed",
|
||||
invert: false,
|
||||
logCredentials: false,
|
||||
videoPath: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("overrides defaults from env vars", () => {
|
||||
expect(
|
||||
loadConfig({
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "2222",
|
||||
MAX_LOOP: "3",
|
||||
PLAYBACK_MODE: "random",
|
||||
ALLOW_USER_CONTROL: "false",
|
||||
SWITCH_DEBOUNCE_MS: "200",
|
||||
LOGIN_DELAY: "500",
|
||||
MAX_CONNECTIONS: "20",
|
||||
MAX_TOTAL_CONNECTIONS: "50",
|
||||
MAX_AUTH_ATTEMPTS: "3",
|
||||
HANDSHAKE_TIMEOUT: "5000",
|
||||
MAX_DIMENSION: "200",
|
||||
FRAME_RESOLUTION: "480",
|
||||
BRIGHTNESS_THRESHOLD: "60",
|
||||
CHARSET: "blocks",
|
||||
INVERT: "true",
|
||||
LOG_CREDENTIALS: "true",
|
||||
VIDEO_PATH: "/videos/clip.mkv",
|
||||
})
|
||||
).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
maxLoop: 3,
|
||||
playbackMode: "random",
|
||||
allowUserControl: false,
|
||||
switchDebounceMs: 200,
|
||||
loginDelay: 500,
|
||||
maxConnections: 20,
|
||||
maxTotalConnections: 50,
|
||||
maxAuthAttempts: 3,
|
||||
handshakeTimeout: 5000,
|
||||
maxDimension: 200,
|
||||
frameResolution: 480,
|
||||
brightnessThreshold: 60,
|
||||
charset: "blocks",
|
||||
invert: true,
|
||||
logCredentials: true,
|
||||
videoPath: "/videos/clip.mkv",
|
||||
});
|
||||
});
|
||||
|
||||
test("boolean env vars are true only for the exact string 'true'", () => {
|
||||
expect(loadConfig({ LOG_CREDENTIALS: "1" }).logCredentials).toBe(false);
|
||||
expect(loadConfig({ INVERT: "yes" }).invert).toBe(false);
|
||||
expect(loadConfig({ INVERT: "true" }).invert).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to defaults for non-numeric and out-of-range values", () => {
|
||||
expect(loadConfig({ PORT: "not-a-number" }).port).toBe(22);
|
||||
expect(loadConfig({ PORT: "99999" }).port).toBe(65535);
|
||||
expect(loadConfig({ MAX_DIMENSION: "0" }).maxDimension).toBe(1);
|
||||
expect(
|
||||
loadConfig({ BRIGHTNESS_THRESHOLD: "999" }).brightnessThreshold
|
||||
).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import fs from "fs";
|
||||
|
||||
export interface Config {
|
||||
host: string;
|
||||
port: number;
|
||||
maxLoop: number;
|
||||
playbackMode: "loop" | "random";
|
||||
allowUserControl: boolean;
|
||||
switchDebounceMs: number;
|
||||
loginDelay: number;
|
||||
maxConnections: number;
|
||||
maxTotalConnections: number;
|
||||
maxAuthAttempts: number;
|
||||
handshakeTimeout: number;
|
||||
maxDimension: number;
|
||||
frameResolution: number;
|
||||
brightnessThreshold: number;
|
||||
charset: string;
|
||||
invert: boolean;
|
||||
logCredentials: boolean;
|
||||
videoPath?: string;
|
||||
}
|
||||
|
||||
function parseIntEnv(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
{ min, max }: { min?: number; max?: number } = {}
|
||||
): number {
|
||||
if (value === undefined || value.trim() === "") return fallback;
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
let result = parsed;
|
||||
if (typeof min === "number") result = Math.max(min, result);
|
||||
if (typeof max === "number") result = Math.min(max, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseBoolEnv(value: string | undefined): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const videoPath = env.VIDEO_PATH?.trim();
|
||||
|
||||
return {
|
||||
host: env.HOST ?? "0.0.0.0",
|
||||
port: parseIntEnv(env.PORT, 22, { min: 1, max: 65535 }),
|
||||
maxLoop: parseIntEnv(env.MAX_LOOP, 5, { min: 1 }),
|
||||
playbackMode:
|
||||
env.PLAYBACK_MODE?.trim().toLowerCase() === "random"
|
||||
? "random"
|
||||
: "loop",
|
||||
allowUserControl: env.ALLOW_USER_CONTROL !== "false",
|
||||
switchDebounceMs: parseIntEnv(env.SWITCH_DEBOUNCE_MS, 120, { min: 0 }),
|
||||
loginDelay: parseIntEnv(env.LOGIN_DELAY, 1500, { min: 0 }),
|
||||
maxConnections: parseIntEnv(env.MAX_CONNECTIONS, 10, { min: 1 }),
|
||||
maxTotalConnections: parseIntEnv(env.MAX_TOTAL_CONNECTIONS, 1000, {
|
||||
min: 1,
|
||||
}),
|
||||
maxAuthAttempts: parseIntEnv(env.MAX_AUTH_ATTEMPTS, 6, { min: 1 }),
|
||||
handshakeTimeout: parseIntEnv(env.HANDSHAKE_TIMEOUT, 10000, { min: 0 }),
|
||||
maxDimension: parseIntEnv(env.MAX_DIMENSION, 512, {
|
||||
min: 1,
|
||||
max: 4096,
|
||||
}),
|
||||
frameResolution: parseIntEnv(env.FRAME_RESOLUTION, 360, {
|
||||
min: 16,
|
||||
max: 1080,
|
||||
}),
|
||||
brightnessThreshold: parseIntEnv(env.BRIGHTNESS_THRESHOLD, 40, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
}),
|
||||
charset: env.CHARSET?.trim() || "detailed",
|
||||
invert: parseBoolEnv(env.INVERT),
|
||||
logCredentials: parseBoolEnv(env.LOG_CREDENTIALS),
|
||||
videoPath: videoPath ? videoPath : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadOptionalTextFile(filePath: string): string | undefined {
|
||||
if (!fs.existsSync(filePath)) return undefined;
|
||||
return fs.readFileSync(filePath).toString();
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { parentPort, workerData } from "worker_threads";
|
||||
import fs from "fs";
|
||||
|
||||
function loadPacked(filename: string): {
|
||||
fps: number;
|
||||
lengths: Uint32Array;
|
||||
packed: Uint8Array;
|
||||
} {
|
||||
const parsed = JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
|
||||
if (
|
||||
!parsed ||
|
||||
!Array.isArray(parsed.frames) ||
|
||||
parsed.frames.length === 0 ||
|
||||
typeof parsed.fps !== "number" ||
|
||||
!Number.isFinite(parsed.fps) ||
|
||||
parsed.fps <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid frames file "${filename}": expected non-empty frames[] and a positive fps`
|
||||
);
|
||||
}
|
||||
|
||||
const raw = parsed.frames as unknown[];
|
||||
const count = raw.length;
|
||||
const lengths = new Uint32Array(count);
|
||||
const bufs: Buffer[] = new Array(count);
|
||||
let total = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const b = Buffer.from(raw[i] as Uint8Array);
|
||||
bufs[i] = b;
|
||||
lengths[i] = b.length;
|
||||
total += b.length;
|
||||
}
|
||||
|
||||
const packed = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
packed.set(bufs[i], off);
|
||||
off += lengths[i];
|
||||
}
|
||||
|
||||
return { fps: parsed.fps, lengths, packed };
|
||||
}
|
||||
|
||||
const { filename } = workerData as { filename: string };
|
||||
const { fps, lengths, packed } = loadPacked(filename);
|
||||
parentPort!.postMessage(
|
||||
{ fps, lengths: lengths.buffer, packed: packed.buffer },
|
||||
[lengths.buffer, packed.buffer] as never
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
// .tsf container, little-endian: "TSFR" | version uint16 | fps float64 |
|
||||
// count uint32 | count × (length uint32, JPEG bytes).
|
||||
const (
|
||||
tsfMagic = "TSFR"
|
||||
tsfVersion = 1
|
||||
)
|
||||
|
||||
func writeTSF(output string, data *FramesContainer) error {
|
||||
f, err := os.Create(output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriterSize(f, 1<<20)
|
||||
if _, err := w.WriteString(tsfMagic); err != nil {
|
||||
return err
|
||||
}
|
||||
var hdr [14]byte
|
||||
binary.LittleEndian.PutUint16(hdr[0:], tsfVersion)
|
||||
binary.LittleEndian.PutUint64(hdr[2:], math.Float64bits(data.FPS))
|
||||
binary.LittleEndian.PutUint32(hdr[10:], uint32(len(data.Frames)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lenBuf [4]byte
|
||||
for _, frame := range data.Frames {
|
||||
binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(frame)))
|
||||
if _, err := w.Write(lenBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func loadTSF(filename string) (*FramesContainer, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invalid := func() error {
|
||||
return fmt.Errorf("invalid frames file %q: corrupt .tsf container", filename)
|
||||
}
|
||||
|
||||
if len(raw) < 18 || string(raw[:4]) != tsfMagic {
|
||||
return nil, invalid()
|
||||
}
|
||||
version := binary.LittleEndian.Uint16(raw[4:])
|
||||
if version != tsfVersion {
|
||||
return nil, fmt.Errorf("unsupported .tsf version %d in %q", version, filename)
|
||||
}
|
||||
fps := math.Float64frombits(binary.LittleEndian.Uint64(raw[6:]))
|
||||
count := binary.LittleEndian.Uint32(raw[14:])
|
||||
|
||||
frames := make([][]byte, 0, count)
|
||||
off := 18
|
||||
for range count {
|
||||
if off+4 > len(raw) {
|
||||
return nil, invalid()
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint32(raw[off:]))
|
||||
off += 4
|
||||
if off+n > len(raw) {
|
||||
return nil, invalid()
|
||||
}
|
||||
frames = append(frames, raw[off:off+n])
|
||||
off += n
|
||||
}
|
||||
|
||||
if len(frames) == 0 || fps <= 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid frames file %q: expected non-empty frames and a positive fps",
|
||||
filename,
|
||||
)
|
||||
}
|
||||
return &FramesContainer{Frames: frames, FPS: fps}, nil
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type FramesContainer struct {
|
||||
Frames [][]byte
|
||||
FPS float64
|
||||
Name string
|
||||
}
|
||||
|
||||
var charsetPresets = map[string]string{
|
||||
"detailed": " .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
|
||||
"standard": " .:-=+*#%@",
|
||||
"simple": " .:oO#@",
|
||||
"blocks": " ░▒▓█",
|
||||
}
|
||||
|
||||
func resolveCharset(charset string) string {
|
||||
if charset == "" {
|
||||
return charsetPresets["detailed"]
|
||||
}
|
||||
if preset, ok := charsetPresets[strings.ToLower(charset)]; ok {
|
||||
return preset
|
||||
}
|
||||
return charset
|
||||
}
|
||||
|
||||
func resizeFrame(frame []byte, width, height int, keepAspectRatio bool) ([]byte, error) {
|
||||
src, err := jpeg.Decode(bytes.NewReader(frame))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst := image.NewGray(image.Rect(0, 0, width, height))
|
||||
if keepAspectRatio {
|
||||
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.Gray{0}), image.Point{}, draw.Src)
|
||||
sb := src.Bounds()
|
||||
sw, sh := sb.Dx(), sb.Dy()
|
||||
scale := min(float64(width)/float64(sw), float64(height)/float64(sh))
|
||||
tw := max(1, int(float64(sw)*scale))
|
||||
th := max(1, int(float64(sh)*scale))
|
||||
x0 := (width - tw) / 2
|
||||
y0 := (height - th) / 2
|
||||
draw.ApproxBiLinear.Scale(dst, image.Rect(x0, y0, x0+tw, y0+th), src, sb, draw.Src, nil)
|
||||
} else {
|
||||
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
|
||||
}
|
||||
return dst.Pix, nil
|
||||
}
|
||||
|
||||
type asciiOptions struct {
|
||||
brightnessThreshold int
|
||||
charset string
|
||||
invert bool
|
||||
}
|
||||
|
||||
func frameToAscii(pixels []byte, options asciiOptions) string {
|
||||
ramp := []rune(resolveCharset(options.charset))
|
||||
total := len(ramp)
|
||||
var b strings.Builder
|
||||
for _, p := range pixels {
|
||||
brightness := int(p) * 100 / 255
|
||||
var index int
|
||||
if brightness < options.brightnessThreshold {
|
||||
index = 0
|
||||
} else {
|
||||
index = brightness * total / 100
|
||||
if index > total-1 {
|
||||
index = total - 1
|
||||
}
|
||||
}
|
||||
if options.invert {
|
||||
index = total - 1 - index
|
||||
}
|
||||
b.WriteRune(ramp[index])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type FrameRenderer struct {
|
||||
frames [][]byte
|
||||
options asciiOptions
|
||||
maxEntries int
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]*list.Element
|
||||
order *list.List
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
key string
|
||||
ascii string
|
||||
}
|
||||
|
||||
func newFrameRenderer(frames [][]byte, options asciiOptions) *FrameRenderer {
|
||||
return &FrameRenderer{
|
||||
frames: frames,
|
||||
options: options,
|
||||
maxEntries: 4096,
|
||||
cache: make(map[string]*list.Element),
|
||||
order: list.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool) (string, error) {
|
||||
key := fmt.Sprintf("%d:%dx%d:%t", index, width, height, keepAspectRatio)
|
||||
|
||||
r.mu.Lock()
|
||||
if el, ok := r.cache[key]; ok {
|
||||
r.order.MoveToBack(el)
|
||||
ascii := el.Value.(*cacheEntry).ascii
|
||||
r.mu.Unlock()
|
||||
return ascii, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
pixels, err := resizeFrame(r.frames[index], width, height, keepAspectRatio)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ascii := frameToAscii(pixels, r.options)
|
||||
|
||||
r.mu.Lock()
|
||||
if _, ok := r.cache[key]; !ok {
|
||||
r.cache[key] = r.order.PushBack(&cacheEntry{key, ascii})
|
||||
if r.order.Len() > r.maxEntries {
|
||||
oldest := r.order.Front()
|
||||
r.order.Remove(oldest)
|
||||
delete(r.cache, oldest.Value.(*cacheEntry).key)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return ascii, nil
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { frameToAscii, resolveCharset, CHARSET_PRESETS } from "./frames";
|
||||
|
||||
describe("frameToAscii", () => {
|
||||
test("returns empty string for an empty buffer", () => {
|
||||
expect(frameToAscii(Buffer.from([]), { brightnessThreshold: 40 })).toBe(
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("maps a zero-brightness pixel to the first (blankest) character", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0]), { brightnessThreshold: 40 })
|
||||
).toBe(" ");
|
||||
});
|
||||
|
||||
test("maps a below-threshold pixel to the first character", () => {
|
||||
// brightness = floor((50/255)*100) = 19, below threshold 40
|
||||
expect(
|
||||
frameToAscii(Buffer.from([50]), { brightnessThreshold: 40 })
|
||||
).toBe(" ");
|
||||
});
|
||||
|
||||
test("maps a mid-range pixel to a mid-range character", () => {
|
||||
// brightness = floor((128/255)*100) = 50
|
||||
expect(
|
||||
frameToAscii(Buffer.from([128]), { brightnessThreshold: 40 })
|
||||
).toBe("n");
|
||||
});
|
||||
|
||||
test("maps a max-brightness pixel to the last (densest) character", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), { brightnessThreshold: 40 })
|
||||
).toBe("$");
|
||||
});
|
||||
|
||||
test("maps multiple pixels in sequence, preserving order", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0, 128, 255]), {
|
||||
brightnessThreshold: 40,
|
||||
})
|
||||
).toBe(" n$");
|
||||
});
|
||||
|
||||
test("uses a named charset preset", () => {
|
||||
// standard ramp " .:-=+*#%@": max brightness -> last char "@"
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "standard",
|
||||
})
|
||||
).toBe("@");
|
||||
});
|
||||
|
||||
test("accepts a custom literal ramp", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([0, 255]), {
|
||||
brightnessThreshold: 0,
|
||||
charset: "AB",
|
||||
})
|
||||
).toBe("AB");
|
||||
});
|
||||
|
||||
test("invert reverses the ramp", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "AB",
|
||||
invert: true,
|
||||
})
|
||||
).toBe("A");
|
||||
});
|
||||
|
||||
test("supports multi-byte (Unicode) ramps", () => {
|
||||
expect(
|
||||
frameToAscii(Buffer.from([255]), {
|
||||
brightnessThreshold: 40,
|
||||
charset: "blocks",
|
||||
})
|
||||
).toBe("█");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCharset", () => {
|
||||
test("returns the detailed preset by default", () => {
|
||||
expect(resolveCharset()).toBe(CHARSET_PRESETS.detailed);
|
||||
});
|
||||
|
||||
test("resolves preset names case-insensitively", () => {
|
||||
expect(resolveCharset("BLOCKS")).toBe(CHARSET_PRESETS.blocks);
|
||||
});
|
||||
|
||||
test("passes through a custom ramp", () => {
|
||||
expect(resolveCharset(" .#@")).toBe(" .#@");
|
||||
});
|
||||
});
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Worker } from "worker_threads";
|
||||
import sharp from "sharp";
|
||||
|
||||
const WORKER_PATH = path.join(__dirname, "frameLoader.worker.ts");
|
||||
|
||||
export interface FramesContainer {
|
||||
frames: Buffer[];
|
||||
fps: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const CHARSET_PRESETS: Record<string, string> = {
|
||||
detailed:
|
||||
" .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
|
||||
standard: " .:-=+*#%@",
|
||||
simple: " .:oO#@",
|
||||
blocks: " ░▒▓█",
|
||||
};
|
||||
|
||||
const DEFAULT_CHARSET = CHARSET_PRESETS.detailed;
|
||||
|
||||
export interface AsciiOptions {
|
||||
brightnessThreshold?: number;
|
||||
charset?: string;
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
export function resolveCharset(charset?: string): string {
|
||||
if (!charset) return DEFAULT_CHARSET;
|
||||
return CHARSET_PRESETS[charset.toLowerCase()] ?? charset;
|
||||
}
|
||||
|
||||
export function loadFrames(filename: string): FramesContainer {
|
||||
const parsed = JSON.parse(fs.readFileSync(filename).toString());
|
||||
|
||||
if (
|
||||
!parsed ||
|
||||
!Array.isArray(parsed.frames) ||
|
||||
parsed.frames.length === 0 ||
|
||||
typeof parsed.fps !== "number" ||
|
||||
!Number.isFinite(parsed.fps) ||
|
||||
parsed.fps <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid frames file "${filename}": expected non-empty frames[] and a positive fps`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as FramesContainer;
|
||||
}
|
||||
|
||||
export function loadFramesAsync(filename: string): Promise<FramesContainer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(WORKER_PATH, { workerData: { filename } });
|
||||
worker.once(
|
||||
"message",
|
||||
(msg: {
|
||||
fps: number;
|
||||
lengths: ArrayBuffer;
|
||||
packed: ArrayBuffer;
|
||||
}) => {
|
||||
const lengths = new Uint32Array(msg.lengths);
|
||||
const frames: Buffer[] = new Array(lengths.length);
|
||||
let off = 0;
|
||||
for (let i = 0; i < lengths.length; i++) {
|
||||
frames[i] = Buffer.from(msg.packed, off, lengths[i]);
|
||||
off += lengths[i];
|
||||
}
|
||||
resolve({ frames, fps: msg.fps });
|
||||
worker.terminate();
|
||||
}
|
||||
);
|
||||
worker.once("error", (err) => {
|
||||
worker.terminate();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resizeFrame(
|
||||
frame: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
keepAspectRatio = false
|
||||
): Promise<Buffer> {
|
||||
return sharp(frame)
|
||||
.resize(width, height, {
|
||||
fit: keepAspectRatio ? "contain" : "fill",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.grayscale()
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export class FrameRenderer {
|
||||
private cache = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly frames: Buffer[],
|
||||
private readonly options: AsciiOptions,
|
||||
private readonly maxEntries = 4096
|
||||
) {}
|
||||
|
||||
async render(
|
||||
index: number,
|
||||
width: number,
|
||||
height: number,
|
||||
keepAspectRatio: boolean
|
||||
): Promise<string> {
|
||||
const key = `${index}:${width}x${height}:${keepAspectRatio ? 1 : 0}`;
|
||||
const cached = this.cache.get(key);
|
||||
if (cached !== undefined) {
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const source = Buffer.isBuffer(this.frames[index])
|
||||
? this.frames[index]
|
||||
: Buffer.from(this.frames[index] as unknown as Uint8Array);
|
||||
const resized = await resizeFrame(
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
keepAspectRatio
|
||||
);
|
||||
const ascii = frameToAscii(resized, this.options);
|
||||
|
||||
this.cache.set(key, ascii);
|
||||
if (this.cache.size > this.maxEntries) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest !== undefined) this.cache.delete(oldest);
|
||||
}
|
||||
return ascii;
|
||||
}
|
||||
}
|
||||
|
||||
export function frameToAscii(
|
||||
pixels: Buffer,
|
||||
options: AsciiOptions = {}
|
||||
): string {
|
||||
const { brightnessThreshold = 40, charset, invert = false } = options;
|
||||
|
||||
const ramp = [...resolveCharset(charset)];
|
||||
const total = ramp.length;
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < pixels.length; i++) {
|
||||
const brightness = Math.floor((pixels[i] / 255) * 100);
|
||||
|
||||
let index: number;
|
||||
if (brightness < brightnessThreshold) {
|
||||
index = 0;
|
||||
} else {
|
||||
index = Math.min(Math.floor((brightness / 100) * total), total - 1);
|
||||
}
|
||||
|
||||
if (invert) index = total - 1 - index;
|
||||
result += ramp[index];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import sshpk from "sshpk";
|
||||
|
||||
function generateAndSave(keyPath: string, type: "rsa" | "ed25519"): void {
|
||||
console.log(`Generating ${type} host key...`);
|
||||
|
||||
const { privateKey } =
|
||||
type === "rsa"
|
||||
? crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
})
|
||||
: crypto.generateKeyPairSync("ed25519", {
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
});
|
||||
|
||||
const parsed = sshpk.parsePrivateKey(privateKey, "pem");
|
||||
fs.writeFileSync(keyPath, parsed.toString("openssh"), { mode: 0o600 });
|
||||
fs.chmodSync(keyPath, 0o600);
|
||||
}
|
||||
|
||||
export function ensureHostKeys(configDir: string): Buffer[] {
|
||||
const keys: Array<{ file: string; type: "rsa" | "ed25519" }> = [
|
||||
{ file: "id_rsa", type: "rsa" },
|
||||
{ file: "id_ed25519", type: "ed25519" },
|
||||
];
|
||||
|
||||
return keys.map(({ file, type }) => {
|
||||
const keyPath = path.join(configDir, file);
|
||||
if (!fs.existsSync(keyPath)) {
|
||||
generateAndSave(keyPath, type);
|
||||
}
|
||||
return fs.readFileSync(keyPath);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func generateAndSave(keyPath, keyType string) error {
|
||||
fmt.Printf("Generating %s host key...\n", keyType)
|
||||
|
||||
var key any
|
||||
var err error
|
||||
if keyType == "rsa" {
|
||||
key, err = rsa.GenerateKey(rand.Reader, 4096)
|
||||
} else {
|
||||
_, key, err = ed25519.GenerateKey(rand.Reader)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := ssh.MarshalPrivateKey(key, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(keyPath, pem.EncodeToMemory(block), 0o600)
|
||||
}
|
||||
|
||||
func ensureHostKeys(configDir string) ([]ssh.Signer, error) {
|
||||
keys := []struct{ file, keyType string }{
|
||||
{"id_rsa", "rsa"},
|
||||
{"id_ed25519", "ed25519"},
|
||||
}
|
||||
|
||||
signers := make([]ssh.Signer, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
keyPath := filepath.Join(configDir, k.file)
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
if err := generateAndSave(keyPath, k.keyType); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate %s host key: %w", k.keyType, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse host key %q: %w", keyPath, err)
|
||||
}
|
||||
signers = append(signers, signer)
|
||||
}
|
||||
return signers, nil
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { loadConfig, loadOptionalTextFile, Config } from "./config";
|
||||
import { ensureHostKeys } from "./hostKeys";
|
||||
import { loadFramesAsync, FramesContainer } from "./frames";
|
||||
import { createServer } from "./server";
|
||||
import videoProcessor from "./videoProcessor";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const FRAMES_DIR = path.join(process.cwd(), "frames");
|
||||
|
||||
function fail(message: string): never {
|
||||
logger.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface Args {
|
||||
generate: boolean;
|
||||
video?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = { generate: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--generate" || arg === "-g") args.generate = true;
|
||||
else if (arg === "--video" || arg === "-v") args.video = argv[++i];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function resolveVideoPath(explicitPath?: string): string | undefined {
|
||||
if (explicitPath) {
|
||||
const explicit = path.resolve(explicitPath);
|
||||
return fs.existsSync(explicit) ? explicit : undefined;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const match = fs
|
||||
.readdirSync(cwd)
|
||||
.filter((name) => path.parse(name).name.toLowerCase() === "video")
|
||||
.sort()
|
||||
.find((name) => fs.statSync(path.join(cwd, name)).isFile());
|
||||
|
||||
return match ? path.join(cwd, match) : undefined;
|
||||
}
|
||||
|
||||
async function generateFrames(
|
||||
config: Config,
|
||||
videoArg?: string
|
||||
): Promise<void> {
|
||||
const videoPath = resolveVideoPath(videoArg ?? config.videoPath);
|
||||
if (!videoPath) {
|
||||
fail(
|
||||
`No source video found. Pass --video <path>, set VIDEO_PATH, or ` +
|
||||
`drop a "video.*" file in "${process.cwd()}".`
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(FRAMES_DIR, { recursive: true });
|
||||
const output = path.join(FRAMES_DIR, `${path.parse(videoPath).name}.json`);
|
||||
|
||||
logger.info(`Generating frames from "${videoPath}" -> ${output}`);
|
||||
try {
|
||||
await videoProcessor.process(videoPath, output, {
|
||||
maxDimension: config.frameResolution,
|
||||
});
|
||||
} catch (err) {
|
||||
fail(
|
||||
`Failed to generate frames from "${videoPath}": ` +
|
||||
(err instanceof Error ? err.message : String(err))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllFrames(): Promise<FramesContainer[]> {
|
||||
const files = fs.existsSync(FRAMES_DIR)
|
||||
? fs
|
||||
.readdirSync(FRAMES_DIR)
|
||||
.filter((name) => name.toLowerCase().endsWith(".json"))
|
||||
.sort()
|
||||
: [];
|
||||
|
||||
if (files.length === 0) {
|
||||
fail(
|
||||
`No frame sets found in "${FRAMES_DIR}". ` +
|
||||
`Generate one first with: bun src/index.ts --generate --video <path>`
|
||||
);
|
||||
}
|
||||
|
||||
const cpus = os.availableParallelism?.() ?? os.cpus().length;
|
||||
const concurrency = Math.min(files.length, Math.max(1, Math.min(cpus, 4)));
|
||||
|
||||
const results: FramesContainer[] = new Array(files.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async () => {
|
||||
for (let i = nextIndex++; i < files.length; i = nextIndex++) {
|
||||
const file = files[i];
|
||||
const filePath = path.join(FRAMES_DIR, file);
|
||||
const sizeMb = (fs.statSync(filePath).size / 1024 / 1024).toFixed(
|
||||
1
|
||||
);
|
||||
logger.info(`Loading ${file} (${sizeMb} MB)...`);
|
||||
const data = await loadFramesAsync(filePath);
|
||||
data.name = file;
|
||||
logger.info(
|
||||
` ${file}: ${data.frames.length} frames @ ${data.fps}fps`
|
||||
);
|
||||
results[i] = data;
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.generate) {
|
||||
await generateFrames(config, args.video);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const bannerText = loadOptionalTextFile(path.join(DATA_DIR, "banner.txt"));
|
||||
const fakeLoginText = loadOptionalTextFile(
|
||||
path.join(DATA_DIR, "fakelogin.txt")
|
||||
);
|
||||
const goodbyeText = loadOptionalTextFile(
|
||||
path.join(DATA_DIR, "goodbye.txt")
|
||||
);
|
||||
|
||||
const hostKeys = ensureHostKeys(DATA_DIR);
|
||||
const videoSets = await loadAllFrames();
|
||||
logger.info(`Loaded ${videoSets.length} frame set(s)`);
|
||||
|
||||
const server = createServer({
|
||||
config,
|
||||
hostKeys,
|
||||
bannerText,
|
||||
fakeLoginText,
|
||||
goodbyeText,
|
||||
videoSets,
|
||||
});
|
||||
|
||||
server.on("error", (err: Error) => {
|
||||
logger.error("Server error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
logger.info(`TrollSSH listening on ${config.host}:${config.port}`);
|
||||
});
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
logger.info(`Received ${signal}, shutting down...`);
|
||||
server.close(() => process.exit(0));
|
||||
// Fail-safe: force exit if connections don't drain promptly.
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
};
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type logLevel int
|
||||
|
||||
const (
|
||||
levelDebug logLevel = 10
|
||||
levelInfo logLevel = 20
|
||||
levelWarn logLevel = 30
|
||||
levelError logLevel = 40
|
||||
)
|
||||
|
||||
var logThreshold = resolveThreshold()
|
||||
|
||||
func resolveThreshold() logLevel {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) {
|
||||
case "debug":
|
||||
return levelDebug
|
||||
case "warn":
|
||||
return levelWarn
|
||||
case "error":
|
||||
return levelError
|
||||
default:
|
||||
return levelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func sanitize(value any) string {
|
||||
return sanitizeN(value, 200)
|
||||
}
|
||||
|
||||
func sanitizeN(value any, maxLength int) string {
|
||||
var str string
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
str = ""
|
||||
case string:
|
||||
str = v
|
||||
default:
|
||||
str = fmt.Sprint(v)
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range str {
|
||||
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
|
||||
b.WriteRune('�')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := []rune(b.String())
|
||||
if len(out) > maxLength {
|
||||
return string(out[:maxLength]) + "…"
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func emit(level logLevel, name string, stream *os.File, args []any) {
|
||||
if level < logThreshold {
|
||||
return
|
||||
}
|
||||
parts := make([]string, len(args))
|
||||
for i, a := range args {
|
||||
if s, ok := a.(string); ok {
|
||||
parts[i] = s
|
||||
} else if b, err := json.Marshal(a); err == nil {
|
||||
parts[i] = string(b)
|
||||
} else {
|
||||
parts[i] = fmt.Sprint(a)
|
||||
}
|
||||
}
|
||||
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
fmt.Fprintf(stream, "[%s] %-5s %s\n", ts, strings.ToUpper(name), strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func logDebug(args ...any) { emit(levelDebug, "debug", os.Stdout, args) }
|
||||
func logInfo(args ...any) { emit(levelInfo, "info", os.Stdout, args) }
|
||||
func logWarn(args ...any) { emit(levelWarn, "warn", os.Stderr, args) }
|
||||
func logError(args ...any) { emit(levelError, "error", os.Stderr, args) }
|
||||
@@ -1,58 +0,0 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function resolveThreshold(): number {
|
||||
const raw = (globalThis.process.env.LOG_LEVEL ?? "info")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return LEVEL_ORDER[raw as LogLevel] ?? LEVEL_ORDER.info;
|
||||
}
|
||||
|
||||
let threshold = resolveThreshold();
|
||||
|
||||
export function sanitize(value: unknown, maxLength = 200): string {
|
||||
let str =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value === undefined
|
||||
? ""
|
||||
: String(value);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
str = str.replace(/[\x00-\x1f\x7f-\x9f]/g, "�");
|
||||
if (str.length > maxLength) str = str.slice(0, maxLength) + "…";
|
||||
return str;
|
||||
}
|
||||
|
||||
function emit(
|
||||
level: LogLevel,
|
||||
stream: NodeJS.WriteStream,
|
||||
args: unknown[]
|
||||
): void {
|
||||
if (LEVEL_ORDER[level] < threshold) return;
|
||||
const ts = new Date().toISOString();
|
||||
const line =
|
||||
`[${ts}] ${level.toUpperCase().padEnd(5)} ` +
|
||||
args
|
||||
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
||||
.join(" ");
|
||||
stream.write(line + "\n");
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (...args: unknown[]) =>
|
||||
emit("debug", globalThis.process.stdout, args),
|
||||
info: (...args: unknown[]) => emit("info", globalThis.process.stdout, args),
|
||||
warn: (...args: unknown[]) => emit("warn", globalThis.process.stderr, args),
|
||||
error: (...args: unknown[]) =>
|
||||
emit("error", globalThis.process.stderr, args),
|
||||
refresh: () => {
|
||||
threshold = resolveThreshold();
|
||||
},
|
||||
sanitize,
|
||||
};
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type cliArgs struct {
|
||||
generate bool
|
||||
video string
|
||||
}
|
||||
|
||||
func parseArgs(argv []string) cliArgs {
|
||||
var args cliArgs
|
||||
for i := 0; i < len(argv); i++ {
|
||||
switch argv[i] {
|
||||
case "--generate", "-g":
|
||||
args.generate = true
|
||||
case "--video", "-v":
|
||||
if i+1 < len(argv) {
|
||||
i++
|
||||
args.video = argv[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func fail(message string) {
|
||||
logError(message)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func resolveVideoPath(explicitPath string) string {
|
||||
abs, err := filepath.Abs(explicitPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
||||
return abs
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func generateFrames(config Config, framesDir, videoArg string) {
|
||||
if videoArg == "" {
|
||||
fail("No source video given. Pass --video <path>.")
|
||||
}
|
||||
videoPath := resolveVideoPath(videoArg)
|
||||
if videoPath == "" {
|
||||
fail(fmt.Sprintf("Source video %q does not exist or is not a file.", videoArg))
|
||||
}
|
||||
|
||||
os.MkdirAll(framesDir, 0o755)
|
||||
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||
output := filepath.Join(framesDir, base+".tsf")
|
||||
|
||||
logInfo(fmt.Sprintf("Generating frames from %q -> %s", videoPath, output))
|
||||
if err := processVideo(videoPath, output, config.FrameResolution); err != nil {
|
||||
fail(fmt.Sprintf("Failed to generate frames from %q: %s", videoPath, err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func loadAllFrames(framesDir string) []*FramesContainer {
|
||||
entries, err := os.ReadDir(framesDir)
|
||||
var files []string
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(strings.ToLower(e.Name()), ".tsf") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
fail(fmt.Sprintf(
|
||||
"No frame sets found in %q. Generate one first with: trollssh --generate --video <path>",
|
||||
framesDir,
|
||||
))
|
||||
}
|
||||
|
||||
concurrency := min(len(files), max(1, min(runtime.NumCPU(), 4)))
|
||||
|
||||
results := make([]*FramesContainer, len(files))
|
||||
errs := make([]error, len(files))
|
||||
var next int
|
||||
var nextMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
worker := func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
nextMu.Lock()
|
||||
i := next
|
||||
next++
|
||||
nextMu.Unlock()
|
||||
if i >= len(files) {
|
||||
return
|
||||
}
|
||||
file := files[i]
|
||||
filePath := filepath.Join(framesDir, file)
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
logInfo(fmt.Sprintf("Loading %s (%.1f MB)...", file, float64(info.Size())/1024/1024))
|
||||
data, err := loadTSF(filePath)
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
data.Name = file
|
||||
logInfo(fmt.Sprintf(" %s: %d frames @ %gfps", file, len(data.Frames), data.FPS))
|
||||
results[i] = data
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(concurrency)
|
||||
for range concurrency {
|
||||
go worker()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
logThreshold = resolveThreshold()
|
||||
|
||||
config := loadConfig()
|
||||
args := parseArgs(os.Args[1:])
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
dataDir := filepath.Join(cwd, "data")
|
||||
framesDir := filepath.Join(cwd, "frames")
|
||||
|
||||
if args.generate {
|
||||
generateFrames(config, framesDir, args.video)
|
||||
return
|
||||
}
|
||||
|
||||
os.MkdirAll(dataDir, 0o755)
|
||||
|
||||
var bannerText, fakeLoginText, goodbyeText *string
|
||||
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "banner.txt")); ok {
|
||||
bannerText = &text
|
||||
}
|
||||
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "fakelogin.txt")); ok {
|
||||
fakeLoginText = &text
|
||||
}
|
||||
if text, ok := loadOptionalTextFile(filepath.Join(dataDir, "goodbye.txt")); ok {
|
||||
goodbyeText = &text
|
||||
}
|
||||
|
||||
hostKeys, err := ensureHostKeys(dataDir)
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
videoSets := loadAllFrames(framesDir)
|
||||
logInfo(fmt.Sprintf("Loaded %d frame set(s)", len(videoSets)))
|
||||
|
||||
server := createServer(ServerDeps{
|
||||
Config: config,
|
||||
HostKeys: hostKeys,
|
||||
BannerText: bannerText,
|
||||
FakeLoginText: fakeLoginText,
|
||||
GoodbyeText: goodbyeText,
|
||||
VideoSets: videoSets,
|
||||
})
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
logInfo(fmt.Sprintf("Received %s, shutting down...", sig))
|
||||
server.Close()
|
||||
time.AfterFunc(5*time.Second, func() { os.Exit(0) })
|
||||
}()
|
||||
|
||||
if err := server.Listen(config.Host, config.Port); err != nil {
|
||||
logError("Server error:", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const clearScreen = "\x1b[2J\x1b[0f"
|
||||
|
||||
type ConnectionTracker struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]int
|
||||
total int
|
||||
}
|
||||
|
||||
func newConnectionTracker() *ConnectionTracker {
|
||||
return &ConnectionTracker{counts: make(map[string]int)}
|
||||
}
|
||||
|
||||
func (t *ConnectionTracker) increment(ip string) int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.counts[ip]++
|
||||
t.total++
|
||||
return t.counts[ip]
|
||||
}
|
||||
|
||||
func (t *ConnectionTracker) decrement(ip string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, ok := t.counts[ip]; !ok {
|
||||
return
|
||||
}
|
||||
t.counts[ip]--
|
||||
if t.total > 0 {
|
||||
t.total--
|
||||
}
|
||||
if t.counts[ip] <= 0 {
|
||||
delete(t.counts, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ConnectionTracker) totalCount() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.total
|
||||
}
|
||||
|
||||
func (t *ConnectionTracker) hasReachedLimits(ip string, maxPerIP, maxTotal int) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.total >= maxTotal || t.counts[ip] >= maxPerIP
|
||||
}
|
||||
|
||||
type frameSet struct {
|
||||
data *FramesContainer
|
||||
renderer *FrameRenderer
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
config Config
|
||||
sshConfig *ssh.ServerConfig
|
||||
sets []frameSet
|
||||
tracker *ConnectionTracker
|
||||
fakeLogin *string
|
||||
goodbye *string
|
||||
listener net.Listener
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type ServerDeps struct {
|
||||
Config Config
|
||||
HostKeys []ssh.Signer
|
||||
BannerText *string
|
||||
FakeLoginText *string
|
||||
GoodbyeText *string
|
||||
VideoSets []*FramesContainer
|
||||
}
|
||||
|
||||
func clampDimension(value, max int) int {
|
||||
if value < 1 {
|
||||
return 1
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func createServer(deps ServerDeps) *Server {
|
||||
config := deps.Config
|
||||
|
||||
sets := make([]frameSet, len(deps.VideoSets))
|
||||
for i, data := range deps.VideoSets {
|
||||
sets[i] = frameSet{
|
||||
data: data,
|
||||
renderer: newFrameRenderer(data.Frames, asciiOptions{
|
||||
brightnessThreshold: config.BrightnessThreshold,
|
||||
charset: config.Charset,
|
||||
invert: config.Invert,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ServerConfig{
|
||||
MaxAuthTries: config.MaxAuthAttempts,
|
||||
PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
ip := hostOnly(conn.RemoteAddr().String())
|
||||
if config.LogCredentials {
|
||||
logInfo(fmt.Sprintf(
|
||||
`Auth attempt from %s method=password user="%s" pass="%s"`,
|
||||
ip, sanitizeN(conn.User(), 128), sanitizeN(string(password), 128),
|
||||
))
|
||||
}
|
||||
if conn.User() == "" || len(password) == 0 {
|
||||
return nil, errors.New("password rejected")
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
sshConfig.KeyExchanges = []string{
|
||||
"mlkem768x25519-sha256",
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"diffie-hellman-group14-sha256",
|
||||
}
|
||||
if deps.BannerText != nil {
|
||||
banner := *deps.BannerText
|
||||
sshConfig.BannerCallback = func(_ ssh.ConnMetadata) string { return banner }
|
||||
}
|
||||
for _, key := range deps.HostKeys {
|
||||
sshConfig.AddHostKey(key)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
config: config,
|
||||
sshConfig: sshConfig,
|
||||
sets: sets,
|
||||
tracker: newConnectionTracker(),
|
||||
fakeLogin: deps.FakeLoginText,
|
||||
goodbye: deps.GoodbyeText,
|
||||
}
|
||||
}
|
||||
|
||||
func hostOnly(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) Listen(host string, port int) error {
|
||||
listener, err := net.Listen("tcp", net.JoinHostPort(host, fmt.Sprint(port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.listener = listener
|
||||
logInfo(fmt.Sprintf("TrollSSH listening on %s:%d", host, port))
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
s.closeOnce.Do(func() {
|
||||
if s.listener != nil {
|
||||
s.listener.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
ip := hostOnly(conn.RemoteAddr().String())
|
||||
|
||||
if s.tracker.hasReachedLimits(ip, s.config.MaxConnections, s.config.MaxTotalConnections) {
|
||||
conn.Close()
|
||||
logWarn("Connection rejected (limit reached) from", ip)
|
||||
return
|
||||
}
|
||||
|
||||
activeForIP := s.tracker.increment(ip)
|
||||
defer s.tracker.decrement(ip)
|
||||
|
||||
if s.config.HandshakeTimeout > 0 {
|
||||
conn.SetDeadline(time.Now().Add(s.config.HandshakeTimeout))
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshConfig)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "i/o timeout") {
|
||||
logWarn("Handshake timeout for", ip)
|
||||
} else {
|
||||
logWarn(fmt.Sprintf("Client error from %s:", ip), sanitize(err.Error()))
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
conn.SetDeadline(time.Time{})
|
||||
logDebug("Handshake from", ip)
|
||||
defer sshConn.Close()
|
||||
|
||||
setIndex := rand.Intn(len(s.sets))
|
||||
logInfo(fmt.Sprintf(
|
||||
"New connection from %s (ip=%d, total=%d) -> playing %q",
|
||||
ip, activeForIP, s.tracker.totalCount(), s.sets[setIndex].data.Name,
|
||||
))
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChannel := range chans {
|
||||
if newChannel.ChannelType() != "session" {
|
||||
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||
continue
|
||||
}
|
||||
channel, requests, err := newChannel.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go s.handleSession(sshConn, channel, requests, ip, setIndex)
|
||||
}
|
||||
logInfo("Client closed connection from", ip)
|
||||
}
|
||||
|
||||
type termSize struct {
|
||||
mu sync.Mutex
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func (t *termSize) set(w, h, maxDim int) {
|
||||
t.mu.Lock()
|
||||
t.width = clampDimension(w, maxDim)
|
||||
t.height = clampDimension(h, maxDim)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *termSize) get() (int, int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.width, t.height
|
||||
}
|
||||
|
||||
func parseDims(payload []byte) (cols, rows int, ok bool) {
|
||||
if len(payload) < 8 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// pty-req prefixes cols/rows with a TERM string; window-change does not.
|
||||
offset := 0
|
||||
strLen := binary.BigEndian.Uint32(payload)
|
||||
if int(strLen)+12 <= len(payload) {
|
||||
offset = 4 + int(strLen)
|
||||
}
|
||||
if len(payload) < offset+8 {
|
||||
return 0, 0, false
|
||||
}
|
||||
cols = int(binary.BigEndian.Uint32(payload[offset:]))
|
||||
rows = int(binary.BigEndian.Uint32(payload[offset+4:]))
|
||||
return cols, rows, true
|
||||
}
|
||||
|
||||
func (s *Server) handleSession(
|
||||
sshConn *ssh.ServerConn,
|
||||
channel ssh.Channel,
|
||||
requests <-chan *ssh.Request,
|
||||
ip string,
|
||||
initialSetIndex int,
|
||||
) {
|
||||
size := &termSize{}
|
||||
size.set(80, 24, s.config.MaxDimension)
|
||||
|
||||
started := false
|
||||
for req := range requests {
|
||||
switch req.Type {
|
||||
case "pty-req":
|
||||
logDebug("Opening pty for session", ip)
|
||||
if cols, rows, ok := parseDims(req.Payload); ok {
|
||||
size.set(cols, rows, s.config.MaxDimension)
|
||||
}
|
||||
req.Reply(true, nil)
|
||||
case "window-change":
|
||||
if len(req.Payload) >= 8 {
|
||||
cols := int(binary.BigEndian.Uint32(req.Payload))
|
||||
rows := int(binary.BigEndian.Uint32(req.Payload[4:]))
|
||||
size.set(cols, rows, s.config.MaxDimension)
|
||||
}
|
||||
if req.WantReply {
|
||||
req.Reply(true, nil)
|
||||
}
|
||||
case "exec":
|
||||
command := ""
|
||||
if len(req.Payload) >= 4 {
|
||||
n := binary.BigEndian.Uint32(req.Payload)
|
||||
if int(n)+4 <= len(req.Payload) {
|
||||
command = string(req.Payload[4 : 4+n])
|
||||
}
|
||||
}
|
||||
logInfo(fmt.Sprintf("Client %s attempted exec: %q", ip, sanitizeN(command, 512)))
|
||||
req.Reply(true, nil)
|
||||
if !started {
|
||||
started = true
|
||||
go s.playVideo(sshConn, channel, size, ip, initialSetIndex, false)
|
||||
}
|
||||
case "shell":
|
||||
logDebug("Opening shell for session", ip)
|
||||
req.Reply(true, nil)
|
||||
if !started {
|
||||
started = true
|
||||
go s.playVideo(sshConn, channel, size, ip, initialSetIndex, false)
|
||||
}
|
||||
default:
|
||||
if req.WantReply {
|
||||
req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pickNextSetIndex(exclude int) int {
|
||||
if len(s.sets) <= 1 {
|
||||
return exclude
|
||||
}
|
||||
next := exclude
|
||||
for next == exclude {
|
||||
next = rand.Intn(len(s.sets))
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Server) playVideo(
|
||||
sshConn *ssh.ServerConn,
|
||||
channel ssh.Channel,
|
||||
size *termSize,
|
||||
ip string,
|
||||
setIndex int,
|
||||
keepAspectRatio bool,
|
||||
) {
|
||||
config := s.config
|
||||
current := s.sets[setIndex]
|
||||
|
||||
w, h := size.get()
|
||||
logDebug(fmt.Sprintf("Terminal size %dx%d for %s", w, h, ip))
|
||||
|
||||
if s.fakeLogin != nil {
|
||||
channel.Write([]byte(clearScreen))
|
||||
channel.Write([]byte(*s.fakeLogin))
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var doneOnce sync.Once
|
||||
closeSession := func() {
|
||||
doneOnce.Do(func() { close(done) })
|
||||
}
|
||||
|
||||
switchCh := make(chan int, 8)
|
||||
go func() {
|
||||
buf := make([]byte, 256)
|
||||
var lastSwitch time.Time
|
||||
for {
|
||||
n, err := channel.Read(buf)
|
||||
if err != nil {
|
||||
closeSession()
|
||||
return
|
||||
}
|
||||
if !config.AllowUserControl {
|
||||
continue
|
||||
}
|
||||
str := string(buf[:n])
|
||||
delta := 0
|
||||
if strings.Contains(str, "\x1b[C") || strings.Contains(str, "\x1b[A") {
|
||||
delta = 1
|
||||
} else if strings.Contains(str, "\x1b[D") || strings.Contains(str, "\x1b[B") {
|
||||
delta = -1
|
||||
}
|
||||
if delta == 0 {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
if now.Sub(lastSwitch) < config.SwitchDebounce {
|
||||
continue
|
||||
}
|
||||
lastSwitch = now
|
||||
select {
|
||||
case switchCh <- delta:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(config.LoginDelay):
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
|
||||
frameInterval := func() time.Duration {
|
||||
return time.Duration(float64(time.Second) / current.data.FPS)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(frameInterval())
|
||||
defer ticker.Stop()
|
||||
|
||||
currentFrame := 0
|
||||
loopCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
|
||||
case delta := <-switchCh:
|
||||
if len(s.sets) <= 1 {
|
||||
continue
|
||||
}
|
||||
setIndex = (setIndex + delta + len(s.sets)) % len(s.sets)
|
||||
current = s.sets[setIndex]
|
||||
currentFrame = 0
|
||||
logDebug(fmt.Sprintf("%s switched to %q", ip, current.data.Name))
|
||||
ticker.Reset(frameInterval())
|
||||
|
||||
case <-ticker.C:
|
||||
w, h := size.get()
|
||||
ascii, err := current.renderer.render(currentFrame, w, h, keepAspectRatio)
|
||||
if err != nil {
|
||||
logError("Render error for", ip, sanitize(err.Error()))
|
||||
sshConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := channel.Write([]byte(clearScreen + ascii)); err != nil {
|
||||
closeSession()
|
||||
return
|
||||
}
|
||||
|
||||
currentFrame++
|
||||
if currentFrame < len(current.data.Frames) {
|
||||
continue
|
||||
}
|
||||
|
||||
currentFrame = 0
|
||||
loopCount++
|
||||
if config.MaxLoop > 0 && loopCount >= config.MaxLoop {
|
||||
channel.Write([]byte(clearScreen))
|
||||
if s.goodbye != nil {
|
||||
channel.Write([]byte(*s.goodbye))
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
logInfo("Playback finished, closing session", ip)
|
||||
channel.Close()
|
||||
sshConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if config.PlaybackMode == PlaybackRandom {
|
||||
setIndex = s.pickNextSetIndex(setIndex)
|
||||
current = s.sets[setIndex]
|
||||
logInfo(fmt.Sprintf(
|
||||
"Playthrough done for %s, switching to %q", ip, current.data.Name,
|
||||
))
|
||||
ticker.Reset(frameInterval())
|
||||
} else if config.MaxLoop > 0 {
|
||||
logInfo(fmt.Sprintf(
|
||||
"Playthrough done for %s, looping %q (%d/%d)",
|
||||
ip, current.data.Name, loopCount, config.MaxLoop,
|
||||
))
|
||||
} else {
|
||||
logInfo(fmt.Sprintf(
|
||||
"Playthrough done for %s, looping %q (%d)",
|
||||
ip, current.data.Name, loopCount,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { ConnectionTracker } from "./server";
|
||||
|
||||
describe("ConnectionTracker", () => {
|
||||
test("count is 0 for an IP that has never connected", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
expect(tracker.count("1.2.3.4")).toBe(0);
|
||||
});
|
||||
|
||||
test("increment raises the count for that IP", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.2.3.4");
|
||||
tracker.increment("1.2.3.4");
|
||||
expect(tracker.count("1.2.3.4")).toBe(2);
|
||||
});
|
||||
|
||||
test("decrement lowers the count for that IP", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.2.3.4");
|
||||
tracker.increment("1.2.3.4");
|
||||
tracker.decrement("1.2.3.4");
|
||||
expect(tracker.count("1.2.3.4")).toBe(1);
|
||||
});
|
||||
|
||||
test("decrementing an IP that was never incremented is a no-op", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.decrement("1.2.3.4");
|
||||
expect(tracker.count("1.2.3.4")).toBe(0);
|
||||
});
|
||||
|
||||
test("hasReachedLimit is true once the count reaches max", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.2.3.4");
|
||||
tracker.increment("1.2.3.4");
|
||||
expect(tracker.hasReachedLimit("1.2.3.4", 2)).toBe(true);
|
||||
expect(tracker.hasReachedLimit("1.2.3.4", 3)).toBe(false);
|
||||
});
|
||||
|
||||
test("tracks separate counts per IP independently", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.1.1.1");
|
||||
tracker.increment("2.2.2.2");
|
||||
tracker.increment("2.2.2.2");
|
||||
expect(tracker.count("1.1.1.1")).toBe(1);
|
||||
expect(tracker.count("2.2.2.2")).toBe(2);
|
||||
});
|
||||
|
||||
test("totalCount aggregates connections across all IPs", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.1.1.1");
|
||||
tracker.increment("2.2.2.2");
|
||||
expect(tracker.totalCount()).toBe(2);
|
||||
tracker.decrement("1.1.1.1");
|
||||
expect(tracker.totalCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("hasReachedTotalLimit reflects the global total", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.1.1.1");
|
||||
tracker.increment("2.2.2.2");
|
||||
expect(tracker.hasReachedTotalLimit(2)).toBe(true);
|
||||
expect(tracker.hasReachedTotalLimit(3)).toBe(false);
|
||||
});
|
||||
|
||||
test("decrementing an unknown IP does not affect the total", () => {
|
||||
const tracker = new ConnectionTracker();
|
||||
tracker.increment("1.1.1.1");
|
||||
tracker.decrement("9.9.9.9");
|
||||
expect(tracker.totalCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
-359
@@ -1,359 +0,0 @@
|
||||
import ssh2 from "ssh2";
|
||||
import { Config } from "./config";
|
||||
import { FramesContainer, FrameRenderer } from "./frames";
|
||||
import { logger, sanitize } from "./logger";
|
||||
|
||||
const MAX_WRITE_BACKLOG_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export class ConnectionTracker {
|
||||
private counts: Record<string, number> = {};
|
||||
private total = 0;
|
||||
|
||||
increment(ip: string): number {
|
||||
this.counts[ip] = (this.counts[ip] ?? 0) + 1;
|
||||
this.total += 1;
|
||||
return this.counts[ip];
|
||||
}
|
||||
|
||||
decrement(ip: string): void {
|
||||
if (typeof this.counts[ip] === "undefined") return;
|
||||
this.counts[ip] -= 1;
|
||||
this.total = Math.max(0, this.total - 1);
|
||||
if (this.counts[ip] <= 0) delete this.counts[ip];
|
||||
}
|
||||
|
||||
count(ip: string): number {
|
||||
return this.counts[ip] ?? 0;
|
||||
}
|
||||
|
||||
totalCount(): number {
|
||||
return this.total;
|
||||
}
|
||||
|
||||
hasReachedLimit(ip: string, max: number): boolean {
|
||||
return this.count(ip) >= max;
|
||||
}
|
||||
|
||||
hasReachedTotalLimit(max: number): boolean {
|
||||
return this.total >= max;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerDeps {
|
||||
config: Config;
|
||||
hostKeys: Buffer[];
|
||||
bannerText?: string;
|
||||
fakeLoginText?: string;
|
||||
goodbyeText?: string;
|
||||
videoSets: FramesContainer[];
|
||||
}
|
||||
|
||||
function clampDimension(value: number, max: number): number {
|
||||
if (!Number.isFinite(value) || value < 1) return 1;
|
||||
return Math.min(Math.floor(value), max);
|
||||
}
|
||||
|
||||
export function createServer(deps: ServerDeps): ssh2.Server {
|
||||
const {
|
||||
config,
|
||||
hostKeys,
|
||||
bannerText,
|
||||
fakeLoginText,
|
||||
goodbyeText,
|
||||
videoSets,
|
||||
} = deps;
|
||||
const tracker = new ConnectionTracker();
|
||||
|
||||
const sets = videoSets.map((data) => ({
|
||||
data,
|
||||
renderer: new FrameRenderer(data.frames, {
|
||||
brightnessThreshold: config.brightnessThreshold,
|
||||
charset: config.charset,
|
||||
invert: config.invert,
|
||||
}),
|
||||
}));
|
||||
|
||||
const server = new ssh2.Server({
|
||||
hostKeys,
|
||||
banner: bannerText,
|
||||
});
|
||||
|
||||
server.on("connection", (client, info) => {
|
||||
if (
|
||||
tracker.hasReachedTotalLimit(config.maxTotalConnections) ||
|
||||
tracker.hasReachedLimit(info.ip, config.maxConnections)
|
||||
) {
|
||||
client.on("error", () => {});
|
||||
client.end();
|
||||
logger.warn("Connection rejected (limit reached) from", info.ip);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeForIp = tracker.increment(info.ip);
|
||||
let currentSetIndex = Math.floor(Math.random() * sets.length);
|
||||
let { data: videoData, renderer } = sets[currentSetIndex];
|
||||
|
||||
const pickNextSetIndex = (exclude: number): number => {
|
||||
if (sets.length <= 1) return exclude;
|
||||
let next = exclude;
|
||||
while (next === exclude) {
|
||||
next = Math.floor(Math.random() * sets.length);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
logger.info(
|
||||
`New connection from ${info.ip} ` +
|
||||
`(ip=${activeForIp}, total=${tracker.totalCount()}) ` +
|
||||
`-> playing "${videoData.name ?? "?"}"`
|
||||
);
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let authAttempts = 0;
|
||||
let ended = false;
|
||||
|
||||
// Force-drop clients that connect but never complete a handshake
|
||||
if (config.handshakeTimeout > 0) {
|
||||
handshakeTimer = setTimeout(() => {
|
||||
logger.warn("Handshake timeout for", info.ip);
|
||||
client.end();
|
||||
}, config.handshakeTimeout);
|
||||
}
|
||||
|
||||
const endSession = () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
tracker.decrement(info.ip);
|
||||
if (interval) clearInterval(interval);
|
||||
if (handshakeTimer) clearTimeout(handshakeTimer);
|
||||
};
|
||||
|
||||
client.on("handshake", () => {
|
||||
logger.debug("Handshake from", info.ip);
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer);
|
||||
handshakeTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
logger.info("Client closed connection from", info.ip);
|
||||
endSession();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
if (err.message === "read ECONNRESET") {
|
||||
logger.debug(
|
||||
"Terminal closed (ECONNRESET) for session",
|
||||
info.ip
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Client error from ${info.ip}:`,
|
||||
sanitize(err.message)
|
||||
);
|
||||
}
|
||||
endSession();
|
||||
});
|
||||
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "password" && config.logCredentials)
|
||||
logger.info(
|
||||
`Auth attempt from ${info.ip} method=${ctx.method} ` +
|
||||
`user="${sanitize(ctx.username, 128)}" ` +
|
||||
`pass="${sanitize(ctx.password, 128)}"`
|
||||
);
|
||||
|
||||
authAttempts += 1;
|
||||
if (authAttempts > config.maxAuthAttempts) {
|
||||
client.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.method !== "password") return ctx.reject(["password"]);
|
||||
if (!ctx.username) return ctx.reject(["password"]);
|
||||
if (!ctx.password) return ctx.reject(["password"]);
|
||||
|
||||
ctx.accept();
|
||||
});
|
||||
|
||||
client.on("session", (accept, _reject) => {
|
||||
const session = accept();
|
||||
|
||||
let height = clampDimension(24, config.maxDimension);
|
||||
let width = clampDimension(80, config.maxDimension);
|
||||
|
||||
session.once("pty", (accept, _reject, data) => {
|
||||
logger.debug("Opening pty for session", info.ip);
|
||||
height = clampDimension(data.rows, config.maxDimension);
|
||||
width = clampDimension(data.cols, config.maxDimension);
|
||||
accept();
|
||||
});
|
||||
|
||||
session.on("window-change", (_accept, _reject, data) => {
|
||||
height = clampDimension(data.rows, config.maxDimension);
|
||||
width = clampDimension(data.cols, config.maxDimension);
|
||||
});
|
||||
|
||||
const playVideo = (
|
||||
stream: ssh2.ServerChannel,
|
||||
keepAspectRatio: boolean
|
||||
) => {
|
||||
stream.setEncoding("utf8");
|
||||
logger.debug(`Terminal size ${width}x${height} for ${info.ip}`);
|
||||
|
||||
if (typeof fakeLoginText !== "undefined") {
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
stream.write(fakeLoginText);
|
||||
}
|
||||
|
||||
let currentFrame = 0;
|
||||
let loopCount = 0;
|
||||
let rendering = false;
|
||||
|
||||
const startRenderLoop = () => {
|
||||
interval = setInterval(async () => {
|
||||
if (ended || stream.destroyed) {
|
||||
if (interval) clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
if (rendering) return;
|
||||
if (
|
||||
(stream.writableLength ?? 0) >
|
||||
MAX_WRITE_BACKLOG_BYTES
|
||||
) {
|
||||
return;
|
||||
}
|
||||
rendering = true;
|
||||
|
||||
try {
|
||||
const ascii = await renderer.render(
|
||||
currentFrame,
|
||||
width,
|
||||
height,
|
||||
keepAspectRatio
|
||||
);
|
||||
|
||||
if (ended || stream.destroyed) return;
|
||||
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
stream.write(ascii);
|
||||
|
||||
currentFrame++;
|
||||
if (currentFrame < videoData.frames.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentFrame = 0;
|
||||
loopCount++;
|
||||
if (loopCount >= config.maxLoop) {
|
||||
if (interval) clearInterval(interval);
|
||||
stream.write("\x1b[2J\x1b[0f");
|
||||
if (typeof goodbyeText !== "undefined") {
|
||||
stream.write(goodbyeText);
|
||||
}
|
||||
setTimeout(() => {
|
||||
logger.info(
|
||||
"Playback finished, closing session",
|
||||
info.ip
|
||||
);
|
||||
stream.end();
|
||||
client.end();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.playbackMode === "random") {
|
||||
currentSetIndex =
|
||||
pickNextSetIndex(currentSetIndex);
|
||||
({ data: videoData, renderer } =
|
||||
sets[currentSetIndex]);
|
||||
logger.info(
|
||||
`Playthrough done for ${info.ip}, ` +
|
||||
`switching to "${videoData.name ?? "?"}"`
|
||||
);
|
||||
if (interval) clearInterval(interval);
|
||||
rendering = false;
|
||||
startRenderLoop();
|
||||
} else {
|
||||
logger.info(
|
||||
`Playthrough done for ${info.ip}, ` +
|
||||
`looping "${videoData.name ?? "?"}" ` +
|
||||
`(${loopCount}/${config.maxLoop})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"Render error for",
|
||||
info.ip,
|
||||
sanitize(
|
||||
err instanceof Error ? err.message : err
|
||||
)
|
||||
);
|
||||
if (interval) clearInterval(interval);
|
||||
client.end();
|
||||
} finally {
|
||||
rendering = false;
|
||||
}
|
||||
}, 1000 / videoData.fps);
|
||||
};
|
||||
|
||||
let lastSwitch = 0;
|
||||
const switchSet = (delta: number) => {
|
||||
if (sets.length <= 1) return;
|
||||
currentSetIndex =
|
||||
(currentSetIndex + delta + sets.length) % sets.length;
|
||||
({ data: videoData, renderer } = sets[currentSetIndex]);
|
||||
currentFrame = 0;
|
||||
logger.debug(
|
||||
`${info.ip} switched to "${videoData.name ?? "?"}"`
|
||||
);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
rendering = false;
|
||||
startRenderLoop();
|
||||
}
|
||||
};
|
||||
|
||||
if (config.allowUserControl) {
|
||||
stream.on("data", (chunk: Buffer | string) => {
|
||||
const s = chunk.toString();
|
||||
let delta = 0;
|
||||
if (s.includes("\x1b[C") || s.includes("\x1b[A"))
|
||||
delta = 1;
|
||||
else if (s.includes("\x1b[D") || s.includes("\x1b[B"))
|
||||
delta = -1;
|
||||
if (delta === 0) return;
|
||||
const now = Date.now();
|
||||
if (now - lastSwitch < config.switchDebounceMs) return;
|
||||
lastSwitch = now;
|
||||
switchSet(delta);
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (ended || stream.destroyed) return;
|
||||
startRenderLoop();
|
||||
}, config.loginDelay);
|
||||
};
|
||||
|
||||
session.once("exec", (accept, _reject, data) => {
|
||||
logger.info(
|
||||
`Client ${info.ip} attempted exec: ` +
|
||||
`"${sanitize(data.command, 512)}"`
|
||||
);
|
||||
const stream = accept();
|
||||
playVideo(stream, false);
|
||||
});
|
||||
|
||||
session.once("shell", (accept, _reject) => {
|
||||
logger.debug("Opening shell for session", info.ip);
|
||||
const stream = accept();
|
||||
playVideo(stream, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import fs from "fs";
|
||||
import { FramesContainer } from "./frames";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const SOI = Buffer.from([0xff, 0xd8]);
|
||||
const EOI = Buffer.from([0xff, 0xd9]);
|
||||
|
||||
export interface ProcessOptions {
|
||||
maxDimension?: number;
|
||||
}
|
||||
|
||||
class JpegFrameSplitter {
|
||||
private buffer = Buffer.alloc(0);
|
||||
|
||||
push(chunk: Buffer): Buffer[] {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
const frames: Buffer[] = [];
|
||||
|
||||
for (;;) {
|
||||
const start = this.buffer.indexOf(SOI);
|
||||
if (start === -1) break;
|
||||
const end = this.buffer.indexOf(EOI, start + SOI.length);
|
||||
if (end === -1) break;
|
||||
|
||||
const frameEnd = end + EOI.length;
|
||||
frames.push(this.buffer.subarray(start, frameEnd));
|
||||
this.buffer = this.buffer.subarray(frameEnd);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
|
||||
export async function process(
|
||||
path: string,
|
||||
output: string,
|
||||
options: ProcessOptions = {}
|
||||
): Promise<void> {
|
||||
const maxDimension = options.maxDimension ?? 320;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(path).ffprobe((err, data) => {
|
||||
if (err) {
|
||||
reject(new Error("ffprobe failed: " + err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = data.streams?.[0];
|
||||
const rate = stream?.r_frame_rate;
|
||||
const fps = rate ? parseFloat(rate) : NaN;
|
||||
if (!Number.isFinite(fps) || fps <= 0) {
|
||||
reject(new Error("Unable to determine a valid video fps"));
|
||||
return;
|
||||
}
|
||||
|
||||
const nbFrames = stream?.nb_frames
|
||||
? parseInt(String(stream.nb_frames), 10)
|
||||
: NaN;
|
||||
const duration = Number(data.format?.duration ?? stream?.duration);
|
||||
const totalFrames = Number.isFinite(nbFrames)
|
||||
? nbFrames
|
||||
: Number.isFinite(duration)
|
||||
? Math.round(duration * fps)
|
||||
: undefined;
|
||||
|
||||
const videoData: FramesContainer = { frames: [], fps };
|
||||
const splitter = new JpegFrameSplitter();
|
||||
|
||||
let settled = false;
|
||||
const fail = (message: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error(message));
|
||||
};
|
||||
|
||||
const reportProgress = (count: number) => {
|
||||
if (totalFrames && totalFrames > 0) {
|
||||
const pct = Math.min(
|
||||
100,
|
||||
Math.round((count / totalFrames) * 100)
|
||||
);
|
||||
globalThis.process.stdout.write(
|
||||
`\rGenerating frames: ${count}/${totalFrames} (${pct}%)`
|
||||
);
|
||||
} else {
|
||||
globalThis.process.stdout.write(
|
||||
`\rGenerating frames: ${count}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ffvideo = ffmpeg(path)
|
||||
.outputOptions("-c:v", "mjpeg")
|
||||
.outputOptions("-q:v", "3")
|
||||
.outputOptions(
|
||||
"-vf",
|
||||
`format=gray,scale=w=${maxDimension}:h=${maxDimension}:` +
|
||||
`force_original_aspect_ratio=decrease`
|
||||
)
|
||||
.outputOptions("-f", "image2pipe")
|
||||
.on("error", (err) => fail("ffmpeg failed: " + err.message));
|
||||
|
||||
const ffstream = ffvideo.pipe();
|
||||
ffstream.on("error", (err: Error) =>
|
||||
fail("ffmpeg stream error: " + err.message)
|
||||
);
|
||||
ffstream.on("data", (chunk: Buffer) => {
|
||||
for (const frame of splitter.push(chunk)) {
|
||||
videoData.frames.push(frame);
|
||||
}
|
||||
reportProgress(videoData.frames.length);
|
||||
});
|
||||
|
||||
ffstream.on("end", () => {
|
||||
if (settled) return;
|
||||
if (videoData.frames.length === 0) {
|
||||
fail("No frames were decoded from the video");
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
globalThis.process.stdout.write("\n");
|
||||
fs.writeFileSync(output, JSON.stringify(videoData));
|
||||
logger.info(
|
||||
`Saved ${videoData.frames.length} frames to ${output}`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
process,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
jpegSOI = []byte{0xff, 0xd8}
|
||||
jpegEOI = []byte{0xff, 0xd9}
|
||||
)
|
||||
|
||||
type jpegFrameSplitter struct {
|
||||
buffer []byte
|
||||
}
|
||||
|
||||
func (s *jpegFrameSplitter) push(chunk []byte) [][]byte {
|
||||
s.buffer = append(s.buffer, chunk...)
|
||||
var frames [][]byte
|
||||
for {
|
||||
start := bytes.Index(s.buffer, jpegSOI)
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := bytes.Index(s.buffer[start+len(jpegSOI):], jpegEOI)
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
frameEnd := start + len(jpegSOI) + end + len(jpegEOI)
|
||||
frame := make([]byte, frameEnd-start)
|
||||
copy(frame, s.buffer[start:frameEnd])
|
||||
frames = append(frames, frame)
|
||||
s.buffer = s.buffer[frameEnd:]
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
type ffprobeOutput struct {
|
||||
Streams []struct {
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
NbFrames string `json:"nb_frames"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
func parseFrameRate(rate string) float64 {
|
||||
if rate == "" {
|
||||
return math.NaN()
|
||||
}
|
||||
parts := strings.SplitN(rate, "/", 2)
|
||||
num, err := strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
den, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil || den == 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
return num / den
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func processVideo(path, output string, maxDimension int) error {
|
||||
probeCmd := exec.Command(
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_streams", "-show_format",
|
||||
"-of", "json", path,
|
||||
)
|
||||
probeOut, err := probeCmd.Output()
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
|
||||
msg = strings.TrimSpace(string(exitErr.Stderr))
|
||||
}
|
||||
return fmt.Errorf("ffprobe failed: %s", msg)
|
||||
}
|
||||
|
||||
var probe ffprobeOutput
|
||||
if err := json.Unmarshal(probeOut, &probe); err != nil {
|
||||
return fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
if len(probe.Streams) == 0 {
|
||||
return fmt.Errorf("unable to determine a valid video fps")
|
||||
}
|
||||
stream := probe.Streams[0]
|
||||
|
||||
fps := parseFrameRate(stream.RFrameRate)
|
||||
if math.IsNaN(fps) || fps <= 0 {
|
||||
return fmt.Errorf("unable to determine a valid video fps")
|
||||
}
|
||||
|
||||
totalFrames := 0
|
||||
if n, err := strconv.Atoi(stream.NbFrames); err == nil {
|
||||
totalFrames = n
|
||||
} else {
|
||||
durStr := probe.Format.Duration
|
||||
if durStr == "" {
|
||||
durStr = stream.Duration
|
||||
}
|
||||
if d, err := strconv.ParseFloat(durStr, 64); err == nil {
|
||||
totalFrames = int(math.Round(d * fps))
|
||||
}
|
||||
}
|
||||
|
||||
vf := fmt.Sprintf(
|
||||
"format=gray,scale=w=%d:h=%d:force_original_aspect_ratio=decrease",
|
||||
maxDimension, maxDimension,
|
||||
)
|
||||
cmd := exec.Command(
|
||||
"ffmpeg", "-i", path,
|
||||
"-c:v", "mjpeg",
|
||||
"-q:v", "3",
|
||||
"-vf", vf,
|
||||
"-f", "image2pipe",
|
||||
"pipe:1",
|
||||
)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
|
||||
videoData := FramesContainer{FPS: fps}
|
||||
splitter := &jpegFrameSplitter{}
|
||||
reportProgress := func(count int) {
|
||||
if totalFrames > 0 {
|
||||
pct := min(100, int(math.Round(float64(count)/float64(totalFrames)*100)))
|
||||
fmt.Printf("\rGenerating frames: %d/%d (%d%%)", count, totalFrames, pct)
|
||||
} else {
|
||||
fmt.Printf("\rGenerating frames: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, 256*1024)
|
||||
for {
|
||||
n, err := stdout.Read(buf)
|
||||
if n > 0 {
|
||||
videoData.Frames = append(videoData.Frames, splitter.push(buf[:n])...)
|
||||
reportProgress(len(videoData.Frames))
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
cmd.Wait()
|
||||
return fmt.Errorf("ffmpeg stream error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return fmt.Errorf("ffmpeg failed: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if len(videoData.Frames) == 0 {
|
||||
return fmt.Errorf("no frames were decoded from the video")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if err := writeTSF(output, &videoData); err != nil {
|
||||
return err
|
||||
}
|
||||
logInfo(fmt.Sprintf("Saved %d frames to %s", len(videoData.Frames), output))
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user