From 6d041dbecfd612d0b560fccf7b6842d2c09bfc2c Mon Sep 17 00:00:00 2001 From: Mason Fox Date: Fri, 23 Jan 2026 15:32:17 -0500 Subject: [PATCH] feat: Add mousehole integration for dynamic MAM token management - Add MOUSEHOLE_ENABLED and MOUSEHOLE_STATE_FILE environment variables - Implement automatic token reading from mousehole's state.json with URL-decoding - Add graceful fallback to static token file when mousehole unavailable - Update Token Manager UI with read-only view for mousehole mode - Block POST/DELETE token endpoints when mousehole is enabled - Add comprehensive documentation in docs/MOUSEHOLE.md - Include docker-compose.mousehole.yml example for easy setup - Bump version to 2.4.0 --- .env.example | 6 + README.md | 11 + app/api/mam-token/route.js | 65 +++++- app/components/TokenManager.jsx | 255 ++++++++++++++-------- docker-compose.mousehole.yml | 48 ++++ docs/MOUSEHOLE.md | 374 ++++++++++++++++++++++++++++++++ package.json | 2 +- src/lib/config.js | 20 ++ 8 files changed, 678 insertions(+), 103 deletions(-) create mode 100644 docker-compose.mousehole.yml create mode 100644 docs/MOUSEHOLE.md diff --git a/.env.example b/.env.example index cdb4c59..a7a2fcc 100644 --- a/.env.example +++ b/.env.example @@ -8,5 +8,11 @@ APP_QB_PASSWORD=adminadmin # ===== MAM auth ===== APP_MAM_USER_AGENT=Scurry/2.0 (+contact) +# ===== Mousehole Integration (Optional) ===== +# Enable dynamic MAM token management via mousehole +# See docs/MOUSEHOLE.md for setup instructions +MOUSEHOLE_ENABLED=false +MOUSEHOLE_STATE_FILE=secrets/state.json + # ===== Browser-exposed defaults (safe only) ===== NEXT_PUBLIC_DEFAULT_CATEGORY=books diff --git a/README.md b/README.md index 098c789..93337fa 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,17 @@ Scurry will organize torrents into the following categories based on their MAM t To take advantage of this, simply create those categories - `books` and `audiobooks` - in qBittorrent, with unique save paths. +### Mousehole Integration (Optional) + +Scurry supports integration with [mousehole](https://github.com/t-mart/mousehole) for automatic MAM token management. When enabled, mousehole dynamically manages your MAM session tokens, eliminating the need for manual updates when your IP address changes. + +**Benefits:** +- Automatic token rotation when IP changes +- No manual token updates required +- Perfect for dynamic IPs or VPN/seedbox setups + +See the [Mousehole Integration Guide](docs/MOUSEHOLE.md) for detailed setup instructions. + ### URL Query String Support You can pre-fill search terms by adding a `q` parameter to the URL: diff --git a/app/api/mam-token/route.js b/app/api/mam-token/route.js index 177ae9b..a354eec 100644 --- a/app/api/mam-token/route.js +++ b/app/api/mam-token/route.js @@ -1,38 +1,68 @@ import { NextResponse } from "next/server"; import { validateMamToken } from "@/src/lib/utilities"; import { MAM_TOKEN_FILE } from "@/src/lib/constants"; -import { readMamToken } from "@/src/lib/config"; +import { readMamToken, isMouseholeMode, config } from "@/src/lib/config"; import fs from "node:fs"; import path from "node:path"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +// Helper function to mask token for display +function maskToken(token) { + return token.length > 10 + ? `${token.slice(0, 6)}...${token.slice(-4)}` + : token.length > 0 ? "***" : ""; +} + // Get current token export async function GET() { try { + const mouseholeEnabled = isMouseholeMode(); + + if (mouseholeEnabled) { + try { + const stateFile = config.mouseholeStateFile; + const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + const decodedToken = decodeURIComponent(state.currentCookie); + + return NextResponse.json({ + exists: true, + token: maskToken(decodedToken), + fullLength: decodedToken.length, + location: stateFile, + mouseholeInfo: { + enabled: true, + stateFile, + lastUpdate: state.lastUpdate?.at || state.lastMam?.request?.at, + mamUpdated: state.lastUpdate?.mamUpdated, + }, + }); + } catch (err) { + // Fall through to regular token file check + console.warn("Mousehole read failed, checking static token:", err.message); + } + } + const exists = fs.existsSync(MAM_TOKEN_FILE); if (!exists) { return NextResponse.json({ exists: false, token: null, - location: MAM_TOKEN_FILE + location: MAM_TOKEN_FILE, + mouseholeInfo: mouseholeEnabled ? { enabled: true, error: "state.json not found" } : { enabled: false }, }); } const token = readMamToken(); - - // Don't send the full token for security - just first/last few chars - const maskedToken = token.length > 10 - ? `${token.slice(0, 6)}...${token.slice(-4)}` - : token.length > 0 ? "***" : ""; return NextResponse.json({ exists: true, - token: maskedToken, + token: maskToken(token), fullLength: token.length, - location: MAM_TOKEN_FILE + location: MAM_TOKEN_FILE, + mouseholeInfo: { enabled: false }, }); } catch (error) { console.error(`Failed to read MAM token: ${error.message}`); @@ -40,7 +70,8 @@ export async function GET() { { exists: false, error: error.message, - location: MAM_TOKEN_FILE + location: MAM_TOKEN_FILE, + mouseholeInfo: { enabled: isMouseholeMode() }, }, { status: 500 } ); @@ -49,6 +80,13 @@ export async function GET() { // Update/create token export async function POST(req) { + if (isMouseholeMode()) { + return NextResponse.json( + { error: "Token management is disabled when MOUSEHOLE_ENABLED=true. Tokens are managed by mousehole." }, + { status: 400 } + ); + } + try { const body = await req.json(); const { token } = body; @@ -108,6 +146,13 @@ export async function POST(req) { // Delete token export async function DELETE() { + if (isMouseholeMode()) { + return NextResponse.json( + { error: "Token management is disabled when MOUSEHOLE_ENABLED=true. Tokens are managed by mousehole." }, + { status: 400 } + ); + } + try { if (fs.existsSync(MAM_TOKEN_FILE)) { fs.unlinkSync(MAM_TOKEN_FILE); diff --git a/app/components/TokenManager.jsx b/app/components/TokenManager.jsx index 7a1bd2d..3552269 100644 --- a/app/components/TokenManager.jsx +++ b/app/components/TokenManager.jsx @@ -106,118 +106,189 @@ export default function TokenManager({ onTokenUpdate }) { } }; + // Check if mousehole mode is enabled + const isMouseholeMode = tokenData.mouseholeInfo?.enabled; + return (

MAM Token Manager

- {/* Current Token Status */} -
-
-
-

- Status: - {tokenData.exists ? 'Token configured' : 'No token found'} + {isMouseholeMode ? ( + // Mousehole Read-Only View +

+
+
+
+ + + +
+
+

+ Token Managed by Mousehole +

+

+ Your MAM token is dynamically managed by the mousehole service. Token editing is disabled. +

+
+
+
+ +
+

+ Status: + {tokenData.exists ? 'Token active' : 'Waiting for mousehole...'}

{tokenData.exists && ( <> -

+

Token: {tokenData.token}

-

+

Length: {tokenData.fullLength} characters

+ {tokenData.mouseholeInfo?.lastUpdate && ( +

+ Last updated: + {new Date(tokenData.mouseholeInfo.lastUpdate).toLocaleString()} + +

+ )} )} -
- - {tokenData.exists && ( -
- - -
- )} -
-
- - {/* Token Input Section */} - {showTokenInput && ( -
-
- -