mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
26 files changed
+2064
-622
No files matched your search
@@ -9,3 +9,4 @@ artifacts/
|
||||
css/tailwind.generated.css
|
||||
ai/reports/
|
||||
*.log
|
||||
coverage/
|
||||
+1
-1
@@ -35,7 +35,7 @@ If we later introduce **dual-licensing** options (e.g., commercial licenses for
|
||||
## Agent PR Conventions
|
||||
|
||||
Automated agents contributing to this repository should follow these rules:
|
||||
- **Atomic Commits**: Keep changes small and self-contained.
|
||||
- **Atomic Commits**: Keep changes small, focused, and self-contained.
|
||||
- **Descriptive Titles**: Use clear, semantic titles (e.g., `fix(ui): resolve upload modal overflow`).
|
||||
- **Commit Messages**: Use the convention `type(scope): description (agent)` (e.g., `fix(ai): formatting (agent)` or `docs(ai): update quickstart (agent)`).
|
||||
- **Reference Issues**: Link to relevant issues in the PR description.
|
||||
|
||||
@@ -154,8 +154,10 @@ http://localhost:8000
|
||||
|
||||
### Quickstart Commands
|
||||
|
||||
- **Run unit tests**: `npm run test:unit`
|
||||
- **Format code**: `npm run format`
|
||||
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full developer guide.
|
||||
|
||||
- **Run unit tests**: `npm run test:unit` (Required before PRs)
|
||||
- **Format code**: `npm run format` (Required before PRs)
|
||||
- **Lint code**: `npm run lint`
|
||||
- **Run DM unit tests**: `npm run test:dm:unit`
|
||||
- **Run DM integration tests**: `npm run test:dm:integration`
|
||||
@@ -212,7 +214,7 @@ const event = buildVideoPostEvent({
|
||||
videoRootId: "my-first-video", // Logical ID, typically matches the d-tag
|
||||
url: "https://example.com/video.mp4",
|
||||
description: "This is a test video post sent via the SDK."
|
||||
// magnet: "magnet:?xt=urn:btih:..." // Optional fallback
|
||||
// magnet: "magnet:?xt=urn:btih:..." // Optional fallback (provide the raw magnet string)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -835,11 +835,21 @@
|
||||
Enter your Cloudflare R2 credentials. Your Account ID (from dashboard) is used to connect. For Public Access URL, use your custom domain or R2.dev subdomain. Ensure the token has "Object Read & Write" permissions.
|
||||
</div>
|
||||
|
||||
<!-- S3 Helper -->
|
||||
<div id="storageS3Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info hidden">
|
||||
Enter your S3-compatible storage details. The Endpoint URL is your provider's API address (e.g., https://s3.amazonaws.com or your MinIO URL).
|
||||
</div>
|
||||
|
||||
<label class="block text-sm text-muted-strong hidden" for="storageEndpoint">
|
||||
<span class="mb-2 block font-medium">Endpoint</span>
|
||||
<input id="storageEndpoint" type="url" class="input" placeholder="https://..." />
|
||||
</label>
|
||||
|
||||
<label class="hidden items-center gap-2 text-sm text-muted-strong" id="storageForcePathStyleLabel">
|
||||
<input id="storageForcePathStyle" type="checkbox" class="checkbox" checked />
|
||||
<span>Force Path Style</span>
|
||||
</label>
|
||||
|
||||
<label class="block text-sm text-muted-strong" for="storageRegion">
|
||||
<span class="mb-2 block font-medium">Region</span>
|
||||
<input id="storageRegion" type="text" class="input" placeholder="auto" value="auto" />
|
||||
|
||||
@@ -427,3 +427,22 @@ export const VIEW_COUNT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
* still letting operators retry if a publish genuinely failed.
|
||||
*/
|
||||
export const ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS = 5 * 60;
|
||||
|
||||
/**
|
||||
* Preferred playback source strategy.
|
||||
*
|
||||
* Options:
|
||||
* - "url": Try CDN/URL first, fall back to WebTorrent.
|
||||
* - "torrent": Try WebTorrent first, fall back to CDN/URL.
|
||||
*/
|
||||
export const DEFAULT_PLAYBACK_SOURCE = "torrent";
|
||||
|
||||
/**
|
||||
* Timeout (in milliseconds) for the initial playback attempt.
|
||||
*
|
||||
* If the preferred source (URL or Torrent) does not reach the "playing" state
|
||||
* within this window, the player will automatically switch to the fallback
|
||||
* source. This prevents users from staring at a loading spinner indefinitely
|
||||
* if the primary source is slow or stalled.
|
||||
*/
|
||||
export const DEFAULT_PLAYBACK_START_TIMEOUT = 2000;
|
||||
@@ -0,0 +1,89 @@
|
||||
# NIP-46 Client Overview
|
||||
|
||||
`js/nostr/nip46Client.js` implements the **Nostr Connect** (NIP-46) protocol, allowing the application to delegate event signing to a remote signer (e.g., a "Bunker" or mobile wallet) instead of managing private keys locally.
|
||||
|
||||
## Architecture
|
||||
|
||||
The communication happens over standard Nostr relays using **Kind 24133** (Nostr Connect) events. These events act as an RPC (Remote Procedure Call) transport layer.
|
||||
|
||||
### Components
|
||||
|
||||
1. **`Nip46RpcClient`**: The central controller. It manages:
|
||||
* **Encryption**: Secures payloads using NIP-44 (XChaCha20) or NIP-04 (legacy AES).
|
||||
* **Subscription**: Listens for responses from the specific remote signer pubkey.
|
||||
* **Queueing**: Uses `Nip46RequestQueue` to serialize and throttle requests (preventing "rate-limited" errors).
|
||||
* **Session**: Persists connection details (remote pubkey, relays) to `localStorage`.
|
||||
|
||||
2. **`Nip46RequestQueue`**: A priority queue ensuring RPC calls are processed sequentially and respect relay rate limits.
|
||||
|
||||
### Connection Flow
|
||||
|
||||
1. **Parsing**: The app receives a connection URI (e.g., `bunker://...` or `nostrconnect://...`).
|
||||
2. **Handshake**: `Nip46RpcClient` initiates a `connect` RPC call.
|
||||
3. **Verification**: If a `secret` was provided in the URI, the client verifies the signer's response matches it.
|
||||
4. **Metadata**: The client fetches the user's public key via `get_public_key`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App
|
||||
participant Client as Nip46RpcClient
|
||||
participant Relay
|
||||
participant Signer as Remote Signer
|
||||
|
||||
App->>Client: connect(bunkerURI)
|
||||
Client->>Client: parse URI & derive ephemeral keys
|
||||
Client->>Relay: Publish Kind 24133 (method: "connect")
|
||||
Relay->>Signer: Deliver Event
|
||||
Signer->>Relay: Publish Kind 24133 (result: "ack")
|
||||
Relay->>Client: Deliver Response
|
||||
Client->>Client: Verify Secret & Permissions
|
||||
Client-->>App: Connected
|
||||
```
|
||||
|
||||
## Signing Flow
|
||||
|
||||
When the app needs to publish a video or update a profile, it calls `signEvent`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App
|
||||
participant Client as Nip46RpcClient
|
||||
participant Relay
|
||||
participant Signer
|
||||
|
||||
App->>Client: signEvent(unsignedEvent)
|
||||
Client->>Client: JSON.stringify(unsignedEvent)
|
||||
Client->>Client: Encrypt Payload (NIP-44)
|
||||
Client->>Relay: Publish Kind 24133 (method: "sign_event")
|
||||
|
||||
Note over Client, Signer: Network Latency / Queueing
|
||||
|
||||
Signer->>Relay: Publish Kind 24133 (result: signedJson)
|
||||
Relay->>Client: Deliver Response
|
||||
Client->>Client: Decrypt & Parse
|
||||
Client-->>App: Signed Event
|
||||
```
|
||||
|
||||
## Security & Encryption
|
||||
|
||||
* **Ephemeral Keys**: The client generates a random local key pair for each session. The user's main private key is **never** transmitted.
|
||||
* **Encryption Negotiation**: The client prefers **NIP-44 v2** but falls back to NIP-04 if the signer doesn't support it.
|
||||
* **Auth Challenges**: If the signer requires out-of-band authentication (e.g., scanning a QR code), it returns an `auth_url` error, which the client bubbles up to the UI.
|
||||
|
||||
## Key Functions
|
||||
|
||||
* `parseNip46ConnectionString(uri)`: specific parser for `bunker://` and `nostrconnect://` URIs, extracting secrets, relays, and remote pubkeys.
|
||||
* `createNip46Cipher(...)`: Factory that creates an encryption interface (encrypt/decrypt) based on available libraries and negotiated algorithms.
|
||||
* `decryptNip46PayloadWithKeys(...)`: Helper to brute-force decrypt an incoming message using all available algorithms (handling legacy vs modern formats).
|
||||
|
||||
## Error Handling
|
||||
|
||||
* **Timeouts**: RPC calls have a default timeout (15s).
|
||||
* **Rate Limiting**: If relays reply with `rate-limited`, the client performs exponential backoff.
|
||||
* **Session Recovery**: On page load, `readStoredNip46Session` rehydrates the session from `localStorage` to resume without re-connecting.
|
||||
|
||||
## When to change
|
||||
|
||||
* **Protocol Updates**: If NIP-46 or NIP-44 standards are updated (e.g. new encryption vectors, new RPC methods).
|
||||
* **Security Audit**: If a security review identifies weaknesses in the ephemeral key handling or encryption flow.
|
||||
* **Relay Logic**: If the strategy for relay selection or rate-limiting needs to be optimized for better connectivity.
|
||||
@@ -511,14 +511,14 @@ class Application {
|
||||
|
||||
isAuthorBlocked(pubkey) {
|
||||
try {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
if (userBlocks && typeof userBlocks.isBlocked === "function") {
|
||||
if (userBlocks.isBlocked(pubkey)) {
|
||||
if (normalized && userBlocks.isBlocked(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isUserLoggedIn()) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
if (
|
||||
normalized &&
|
||||
moderationService &&
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
VIEW_COUNT_CACHE_TTL_MS,
|
||||
ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS,
|
||||
DEFAULT_PLAYBACK_SOURCE,
|
||||
DEFAULT_PLAYBACK_START_TIMEOUT,
|
||||
} from "../config/instance-config.js";
|
||||
|
||||
export const WATCH_HISTORY_VERSION_TAG_VALUE = "2";
|
||||
@@ -133,3 +135,5 @@ export { VIEW_COUNT_DEDUPE_WINDOW_SECONDS };
|
||||
export { VIEW_COUNT_BACKFILL_MAX_DAYS };
|
||||
export { VIEW_COUNT_CACHE_TTL_MS };
|
||||
export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS };
|
||||
export { DEFAULT_PLAYBACK_SOURCE };
|
||||
export { DEFAULT_PLAYBACK_START_TIMEOUT };
|
||||
+9
-1
@@ -4,6 +4,8 @@ import {
|
||||
DEFAULT_TRUST_SEED_NPUBS as CONFIG_DEFAULT_TRUST_SEED_NPUBS,
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
DEFAULT_PLAYBACK_SOURCE,
|
||||
DEFAULT_PLAYBACK_START_TIMEOUT,
|
||||
IS_DEV_MODE,
|
||||
} from "./config.js";
|
||||
|
||||
@@ -134,6 +136,11 @@ const SANITIZED_DEFAULT_TRUST_SEED_NPUBS = Object.freeze(
|
||||
sanitizeTrustSeedList(CONFIG_DEFAULT_TRUST_SEED_NPUBS)
|
||||
);
|
||||
|
||||
const SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT = coerceNonNegativeInteger(
|
||||
DEFAULT_PLAYBACK_START_TIMEOUT,
|
||||
3000
|
||||
);
|
||||
|
||||
export const DEFAULT_BLUR_THRESHOLD = SANITIZED_DEFAULT_BLUR_THRESHOLD;
|
||||
export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD =
|
||||
SANITIZED_DEFAULT_AUTOPLAY_BLOCK_THRESHOLD;
|
||||
@@ -142,9 +149,10 @@ export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD =
|
||||
export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD =
|
||||
SANITIZED_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD;
|
||||
export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS;
|
||||
export const PLAYBACK_START_TIMEOUT = SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT;
|
||||
|
||||
const DEFAULT_FLAGS = Object.freeze({
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player
|
||||
FEATURE_WATCH_HISTORY_V2: true,
|
||||
FEATURE_PUBLISH_NIP71: false,
|
||||
FEATURE_HASHTAG_PREFERENCES: false,
|
||||
|
||||
+28
-5
@@ -3959,13 +3959,36 @@ export class NostrClient {
|
||||
*/
|
||||
async login(options = {}) {
|
||||
try {
|
||||
try {
|
||||
await waitForNip07Extension();
|
||||
} catch (waitError) {
|
||||
devLogger.log("Timed out waiting for extension injection:", waitError);
|
||||
let extension = null;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (!extension && attempts < maxAttempts) {
|
||||
try {
|
||||
await waitForNip07Extension();
|
||||
extension = window.nostr;
|
||||
} catch (waitError) {
|
||||
devLogger.log(
|
||||
`Timed out waiting for extension injection (attempt ${
|
||||
attempts + 1
|
||||
}/${maxAttempts}):`,
|
||||
waitError,
|
||||
);
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
break;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
devLogger.log("Retrying NIP-07 detection...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const extension = window.nostr;
|
||||
extension = window.nostr;
|
||||
|
||||
if (!extension) {
|
||||
devLogger.log("No Nostr extension found");
|
||||
throw new Error(
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// js/nostr/nip46Client.js
|
||||
|
||||
/**
|
||||
* NIP-46 (Nostr Connect) Client Implementation.
|
||||
*
|
||||
* This module provides a client for the Nostr Connect protocol, allowing the application
|
||||
* to delegate event signing to a remote signer (e.g., a "Bunker" or mobile wallet).
|
||||
*
|
||||
* Key components:
|
||||
* - `Nip46RpcClient`: The main class managing the RPC session.
|
||||
* - `parseNip46ConnectionString`: URI parser for `bunker://` and `nostrconnect://`.
|
||||
*
|
||||
* For a detailed architecture overview, see `docs/nip46-client-overview.md`.
|
||||
*/
|
||||
|
||||
import { isDevMode } from "../config.js";
|
||||
import {
|
||||
DEFAULT_RELAY_URLS,
|
||||
@@ -30,6 +43,10 @@ export const NIP46_ENCRYPTION_ALGORITHMS = Object.freeze([
|
||||
"nip04",
|
||||
]);
|
||||
|
||||
/**
|
||||
* resolves the appropriate storage mechanism (localStorage or polyfill) for NIP-46 sessions.
|
||||
* @returns {Storage|null} The storage interface or null if unavailable.
|
||||
*/
|
||||
export function getNip46Storage() {
|
||||
if (typeof localStorage !== "undefined" && localStorage) {
|
||||
return localStorage;
|
||||
@@ -42,6 +59,13 @@ export function getNip46Storage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans and normalizes a list of relay URLs.
|
||||
* Removes duplicates, trailing slashes, and invalid URLs.
|
||||
*
|
||||
* @param {string[]} list - The raw list of relay URLs.
|
||||
* @returns {string[]} The sanitized list of relay URLs.
|
||||
*/
|
||||
export function sanitizeRelayList(list) {
|
||||
const seen = new Set();
|
||||
const sanitized = [];
|
||||
@@ -86,6 +110,13 @@ export function sanitizeRelayList(list) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes a NIP-46 session object from storage.
|
||||
* Ensures strict schema compliance to prevent prototype pollution or corrupted state.
|
||||
*
|
||||
* @param {object} candidate - The raw session object from storage.
|
||||
* @returns {object|null} The valid session object or null if invalid.
|
||||
*/
|
||||
export function sanitizeStoredNip46Session(candidate) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
@@ -484,6 +515,13 @@ export function normalizeNostrPubkey(candidate) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a NIP-46 connection URI (bunker:// or nostrconnect://) into a structured configuration.
|
||||
* Handles decoding of standard params (relay, secret, perms) and bunker-specific logic (user hints).
|
||||
*
|
||||
* @param {string} uri - The connection URI.
|
||||
* @returns {object|null} The parsed configuration object or null if invalid.
|
||||
*/
|
||||
export function parseNip46ConnectionString(uri) {
|
||||
const value = typeof uri === "string" ? uri.trim() : "";
|
||||
if (!value) {
|
||||
@@ -1066,6 +1104,17 @@ function resolveAvailableNip46Ciphers(
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryption interface (encrypt/decrypt) for NIP-46 communication.
|
||||
* Automatically selects the best available algorithm (NIP-44 v2 preferred) supported by the environment.
|
||||
*
|
||||
* @param {object} tools - The nostr-tools library instance.
|
||||
* @param {string} privateKey - The local client's private key (hex).
|
||||
* @param {string} remotePubkey - The remote signer's public key (hex).
|
||||
* @param {object} [options] - Configuration options.
|
||||
* @param {string} [options.preferredAlgorithm] - Force a specific algorithm ("nip44.v2", "nip04").
|
||||
* @returns {object} An object with `encrypt(text)` and `decrypt(text)` methods.
|
||||
*/
|
||||
export function createNip46Cipher(tools, privateKey, remotePubkey, options = {}) {
|
||||
const available = resolveAvailableNip46Ciphers(tools, privateKey, remotePubkey, options);
|
||||
|
||||
@@ -1076,6 +1125,15 @@ export function createNip46Cipher(tools, privateKey, remotePubkey, options = {})
|
||||
return available[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to decrypt a NIP-46 payload using all available encryption algorithms.
|
||||
* Useful when the specific algorithm used by the remote signer is unknown (e.g., during handshake).
|
||||
*
|
||||
* @param {string} privateKey - The local client's private key.
|
||||
* @param {string} remotePubkey - The remote signer's public key.
|
||||
* @param {string} ciphertext - The encrypted payload.
|
||||
* @returns {Promise<object>} The decrypted result containing `{ plaintext, algorithm }`.
|
||||
*/
|
||||
export async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext) {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (!tools) {
|
||||
@@ -1333,7 +1391,28 @@ export async function attemptDecryptNip46HandshakePayload({
|
||||
});
|
||||
throw failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a NIP-46 (Nostr Connect) remote signing session.
|
||||
* Handles the RPC loop: Encrypt Request -> Publish Event -> Wait for Response -> Decrypt.
|
||||
*
|
||||
* It uses a "Stale-While-Revalidate" approach for subscriptions and maintains a
|
||||
* priority queue for requests to avoid rate-limiting issues with relays.
|
||||
*/
|
||||
export class Nip46RpcClient {
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {NostrClient} [config.nostrClient] - The main app client (for pool/relays).
|
||||
* @param {string} config.clientPrivateKey - The ephemeral local private key (hex).
|
||||
* @param {string} [config.clientPublicKey] - The ephemeral local public key (hex).
|
||||
* @param {string} config.remotePubkey - The remote signer's public key (hex).
|
||||
* @param {string[]} [config.relays] - List of relay URLs to use for the RPC channel.
|
||||
* @param {string} [config.encryption] - Preferred encryption algorithm ("nip44.v2" or "nip04").
|
||||
* @param {string} [config.secret] - Optional shared secret (for initial connection verification).
|
||||
* @param {string} [config.permissions] - Requested permissions (comma-separated).
|
||||
* @param {object} [config.metadata] - Metadata to identify this app to the signer.
|
||||
* @param {function} [config.signEvent] - Function to sign the RPC request events.
|
||||
*/
|
||||
constructor({
|
||||
nostrClient,
|
||||
clientPrivateKey,
|
||||
@@ -1647,6 +1726,18 @@ export class Nip46RpcClient {
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an RPC command to the remote signer and awaits the response.
|
||||
* Handles encryption, signing, publishing, and timeouts.
|
||||
*
|
||||
* @param {string} method - The RPC method name (e.g., "connect", "sign_event").
|
||||
* @param {Array} [params=[]] - The parameters for the method.
|
||||
* @param {object} [options={}] - Options for this specific call.
|
||||
* @param {number} [options.timeoutMs] - Timeout in milliseconds.
|
||||
* @param {number} [options.priority] - Priority level (High/Normal/Low).
|
||||
* @param {number} [options.retries] - Number of retry attempts.
|
||||
* @returns {Promise<any>} The result from the remote signer.
|
||||
*/
|
||||
async sendRpc(method, params = [], options = {}) {
|
||||
if (this.destroyed) {
|
||||
throw new Error("Remote signer session has been disposed.");
|
||||
@@ -1855,6 +1946,14 @@ export class Nip46RpcClient {
|
||||
}, priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the connection handshake ("connect" method).
|
||||
* Verifies that the remote signer acknowledges the shared secret (if one exists).
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.permissions] - Override permissions to request.
|
||||
* @returns {Promise<string>} The response result (usually "ack").
|
||||
*/
|
||||
async connect({ permissions } = {}) {
|
||||
const params = [this.remotePubkey];
|
||||
const requestedPermissions = permissions || this.permissions || "";
|
||||
@@ -1935,6 +2034,14 @@ export class Nip46RpcClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the remote signer to sign a Nostr event.
|
||||
*
|
||||
* @param {object} event - The raw event object (unsigned).
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.timeoutMs] - Custom timeout.
|
||||
* @returns {Promise<object>} The fully signed event object.
|
||||
*/
|
||||
async signEvent(event, options = {}) {
|
||||
if (!event || typeof event !== "object") {
|
||||
throw new Error("A Nostr event is required for remote signing.");
|
||||
|
||||
@@ -1709,6 +1709,7 @@ export class NostrService {
|
||||
}
|
||||
|
||||
// Re-populate cache indexing
|
||||
this.cacheVideos(Array.from(localAll.values()));
|
||||
this.ensureVideosMap();
|
||||
this.markAuthorIndexDirty();
|
||||
|
||||
|
||||
+178
-102
@@ -1,4 +1,5 @@
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
|
||||
// js/services/playbackService.js
|
||||
|
||||
/**
|
||||
@@ -70,6 +71,7 @@ export class PlaybackService {
|
||||
isValidMagnetUri,
|
||||
urlFirstEnabled = true,
|
||||
analyticsCallbacks = {},
|
||||
playbackStartTimeout = PLAYBACK_START_TIMEOUT,
|
||||
} = {}) {
|
||||
if (typeof logger === "function") {
|
||||
this.logger = logger;
|
||||
@@ -84,6 +86,7 @@ export class PlaybackService {
|
||||
this.isValidMagnetUri = isValidMagnetUri;
|
||||
this.urlFirstEnabled = !!urlFirstEnabled;
|
||||
this.analyticsCallbacks = analyticsCallbacks || {};
|
||||
this.playbackStartTimeout = playbackStartTimeout;
|
||||
this.currentSession = null;
|
||||
}
|
||||
|
||||
@@ -261,7 +264,10 @@ export class PlaybackService {
|
||||
}
|
||||
|
||||
createSession(options = {}) {
|
||||
const session = new PlaybackSession(this, options);
|
||||
const session = new PlaybackSession(this, {
|
||||
...options,
|
||||
playbackStartTimeout: this.playbackStartTimeout,
|
||||
});
|
||||
this.currentSession = session;
|
||||
return session;
|
||||
}
|
||||
@@ -480,6 +486,7 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
autoplay,
|
||||
unsupportedBtihMessage = DEFAULT_UNSUPPORTED_BTITH_MESSAGE,
|
||||
forcedSource = null,
|
||||
playbackStartTimeout = PLAYBACK_START_TIMEOUT,
|
||||
} = this.options;
|
||||
|
||||
this.result = { source: null };
|
||||
@@ -550,57 +557,40 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Fallback Logic --
|
||||
// This function switches from the "URL" path to the "Torrent" path.
|
||||
let fallbackStarted = false;
|
||||
const startTorrentFallback = async (reason) => {
|
||||
if (fallbackStarted) {
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Duplicate fallback request ignored (${reason}).`
|
||||
);
|
||||
return null;
|
||||
const resetVideoElement = () => {
|
||||
if (!activeVideoEl) return;
|
||||
try {
|
||||
activeVideoEl.pause();
|
||||
} catch (err) {
|
||||
this.service.log("[playVideoWithFallback] Pause threw during reset:", err);
|
||||
}
|
||||
fallbackStarted = true;
|
||||
try {
|
||||
activeVideoEl.removeAttribute("src");
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
activeVideoEl.src = "";
|
||||
activeVideoEl.srcObject = null;
|
||||
try {
|
||||
activeVideoEl.load();
|
||||
} catch (err) {
|
||||
this.service.log("[playVideoWithFallback] Load threw during reset:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Attempt Torrent Logic ---
|
||||
let torrentAttempted = false;
|
||||
const attemptTorrentPlayback = async (reason) => {
|
||||
if (torrentAttempted) return null;
|
||||
torrentAttempted = true;
|
||||
|
||||
this.cleanupWatchdog();
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
|
||||
// Reset the video element state so WebTorrent can take over clean.
|
||||
if (activeVideoEl) {
|
||||
try {
|
||||
activeVideoEl.pause();
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Ignoring pause error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
try {
|
||||
activeVideoEl.removeAttribute("src");
|
||||
} catch (err) {
|
||||
// ignore attribute removal errors
|
||||
}
|
||||
activeVideoEl.src = "";
|
||||
activeVideoEl.srcObject = null;
|
||||
try {
|
||||
activeVideoEl.load();
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Ignoring load error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
resetVideoElement();
|
||||
|
||||
if (!this.magnetForPlayback) {
|
||||
const message =
|
||||
"Hosted playback failed and no magnet fallback is available.";
|
||||
this.emit("status", { message });
|
||||
this.emit("sourcechange", { source: null });
|
||||
const error = new Error(message);
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
// No magnet available to try
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -609,33 +599,29 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
this.emit("fallback", { reason });
|
||||
|
||||
if (typeof playViaWebTorrent !== "function") {
|
||||
const error = new Error("No torrent playback handler provided.");
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message: error.message });
|
||||
this.result = { source: null, error };
|
||||
return null;
|
||||
throw new Error("No torrent playback handler provided.");
|
||||
}
|
||||
|
||||
// Delegate to the WebTorrent driver (see js/webtorrent.js interactions in app.js)
|
||||
const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
|
||||
fallbackMagnet: this.fallbackMagnet,
|
||||
urlList: webSeedCandidates,
|
||||
});
|
||||
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" });
|
||||
this.emit("sourcechange", { source: "torrent" });
|
||||
if (typeof autoplay === "function") {
|
||||
autoplay();
|
||||
}
|
||||
this.result = { source: "torrent", torrentInstance };
|
||||
return torrentInstance;
|
||||
|
||||
const result = { source: "torrent", torrentInstance };
|
||||
this.result = result;
|
||||
return result;
|
||||
};
|
||||
|
||||
// -- Primary Path: Direct URL --
|
||||
if (
|
||||
this.service.urlFirstEnabled &&
|
||||
httpsUrl &&
|
||||
forcedSource !== "torrent"
|
||||
) {
|
||||
// --- Attempt URL Logic ---
|
||||
const attemptHostedPlayback = async () => {
|
||||
if (!httpsUrl) return null;
|
||||
|
||||
this.emit("status", { message: "Checking hosted URL..." });
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
|
||||
@@ -644,20 +630,18 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
let hostedStatusResolved = false;
|
||||
const hostedStatusHandlers = [];
|
||||
const addHostedStatusListener = (eventName, handler, options) => {
|
||||
if (!activeVideoEl) {
|
||||
return;
|
||||
}
|
||||
if (!activeVideoEl) return;
|
||||
activeVideoEl.addEventListener(eventName, handler, options);
|
||||
hostedStatusHandlers.push([eventName, handler, options]);
|
||||
};
|
||||
|
||||
const markHostedUrlAsLive = () => {
|
||||
if (hostedStatusResolved) {
|
||||
return;
|
||||
}
|
||||
if (hostedStatusResolved) return;
|
||||
hostedStatusResolved = true;
|
||||
this.emit("status", { message: HOSTED_URL_SUCCESS_MESSAGE });
|
||||
cleanupHostedUrlStatusListeners();
|
||||
};
|
||||
|
||||
const maybeMarkHostedUrl = () => {
|
||||
if (
|
||||
hostedStatusResolved ||
|
||||
@@ -670,6 +654,7 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
markHostedUrlAsLive();
|
||||
}
|
||||
};
|
||||
|
||||
cleanupHostedUrlStatusListeners = () => {
|
||||
if (!hostedStatusHandlers.length || !activeVideoEl) {
|
||||
hostedStatusHandlers.length = 0;
|
||||
@@ -682,6 +667,7 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
};
|
||||
|
||||
addHostedStatusListener("playing", markHostedUrlAsLive, { once: true });
|
||||
addHostedStatusListener("loadeddata", maybeMarkHostedUrl);
|
||||
addHostedStatusListener("canplay", maybeMarkHostedUrl);
|
||||
@@ -718,23 +704,18 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
|
||||
const playbackOutcomePromise = new Promise((resolve) => {
|
||||
outcomeResolver = (value) => {
|
||||
if (outcomeResolved) {
|
||||
return;
|
||||
}
|
||||
if (outcomeResolved) return;
|
||||
outcomeResolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
});
|
||||
|
||||
// Install the watchdogs before we assign src and play.
|
||||
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
|
||||
this.registerWatchdogs(activeVideoEl, {
|
||||
stallMs,
|
||||
onSuccess: () => outcomeResolver({ status: "success" }),
|
||||
onFallback: (reason) => {
|
||||
if (autoplayBlocked && reason === "stall") {
|
||||
// If browser blocked autoplay, a stall is expected (video is paused).
|
||||
// Don't fallback yet; wait for user interaction.
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
|
||||
);
|
||||
@@ -742,15 +723,15 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
message: "Press play to start the hosted video.",
|
||||
});
|
||||
this.emit("autoplay-blocked", { reason });
|
||||
attachWatchdogs({ stallMs: 0 }); // Disable timer
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
outcomeResolver({ status: "fallback", reason });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Use default stall timeout here; the initial start timeout is handled by the race wrapper
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
|
||||
const handleFatalPlaybackError = (err) => {
|
||||
@@ -804,39 +785,133 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
this.emit("sourcechange", { source: "url" });
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
this.result = { source: "url" };
|
||||
return this.result;
|
||||
const result = { source: "url" };
|
||||
this.result = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// If we are here, the watchdog triggered a fallback.
|
||||
const fallbackReason =
|
||||
playbackOutcome?.reason || "watchdog-triggered";
|
||||
const torrentInstance = await startTorrentFallback(fallbackReason);
|
||||
this.result = torrentInstance
|
||||
? { source: "torrent", torrentInstance }
|
||||
: this.result;
|
||||
return this.result;
|
||||
// Fallback triggered
|
||||
const fallbackReason = playbackOutcome?.reason || "watchdog-triggered";
|
||||
return { status: "fallback", reason: fallbackReason };
|
||||
}
|
||||
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Hosted URL probe reported "${probeOutcome}"; deferring to WebTorrent.`
|
||||
);
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
return { status: "fallback", reason: "probe-failed" };
|
||||
};
|
||||
|
||||
// --- Timeout Wrapper ---
|
||||
const withTimeout = (promise, ms, label = "Operation") => {
|
||||
if (!ms || ms <= 0) return promise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.service.log(`[playVideoWithFallback] ${label} timed out after ${ms}ms.`);
|
||||
resolve({ status: "fallback", reason: "timeout", source: "timeout" });
|
||||
}, ms);
|
||||
|
||||
promise
|
||||
.then((result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// --- Execution Flow ---
|
||||
|
||||
let tryUrlFirst = this.service.urlFirstEnabled;
|
||||
if (forcedSource === "url") tryUrlFirst = true;
|
||||
if (forcedSource === "torrent") tryUrlFirst = false;
|
||||
|
||||
const effectiveTimeout = forcedSource ? 0 : playbackStartTimeout;
|
||||
|
||||
if (tryUrlFirst) {
|
||||
if (httpsUrl) {
|
||||
// Wrap URL attempt in timeout
|
||||
const urlResult = await withTimeout(
|
||||
attemptHostedPlayback(),
|
||||
effectiveTimeout,
|
||||
"URL Playback"
|
||||
);
|
||||
|
||||
if (urlResult && urlResult.source === "url") {
|
||||
return urlResult;
|
||||
}
|
||||
|
||||
// If timeout or failure, proceed to fallback
|
||||
if (urlResult?.reason === "timeout") {
|
||||
// Need to ensure URL probing/playback is effectively cancelled
|
||||
this.cleanupWatchdog();
|
||||
cleanupHostedUrlStatusListeners();
|
||||
resetVideoElement();
|
||||
}
|
||||
|
||||
const fallbackReason = urlResult?.reason || "url-unavailable";
|
||||
if (this.magnetForPlayback && forcedSource !== "url") {
|
||||
return await attemptTorrentPlayback(fallbackReason);
|
||||
}
|
||||
} else if (this.magnetForPlayback && forcedSource !== "url") {
|
||||
return await attemptTorrentPlayback("url-missing");
|
||||
}
|
||||
} else {
|
||||
// Try Torrent First
|
||||
if (this.magnetForPlayback) {
|
||||
try {
|
||||
// Wrap Torrent attempt in timeout
|
||||
const torrentResult = await withTimeout(
|
||||
attemptTorrentPlayback("preference"),
|
||||
effectiveTimeout,
|
||||
"Torrent Playback"
|
||||
);
|
||||
|
||||
if (torrentResult && torrentResult.source === "torrent") {
|
||||
return torrentResult;
|
||||
}
|
||||
|
||||
if (torrentResult?.reason === "timeout") {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Torrent timed out; cleaning up."
|
||||
);
|
||||
if (
|
||||
this.service.torrentClient &&
|
||||
typeof this.service.torrentClient.cleanup === "function"
|
||||
) {
|
||||
await this.service.torrentClient.cleanup();
|
||||
}
|
||||
resetVideoElement();
|
||||
}
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Torrent preference failed, trying URL:",
|
||||
err
|
||||
);
|
||||
if (
|
||||
this.service.torrentClient &&
|
||||
typeof this.service.torrentClient.cleanup === "function"
|
||||
) {
|
||||
await this.service.torrentClient.cleanup();
|
||||
}
|
||||
resetVideoElement();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to URL
|
||||
if (httpsUrl && forcedSource !== "torrent") {
|
||||
const urlResult = await attemptHostedPlayback();
|
||||
if (urlResult && urlResult.source === "url") {
|
||||
return urlResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Secondary Path: Magnet / Torrent --
|
||||
if (this.magnetForPlayback) {
|
||||
const torrentInstance = await startTorrentFallback("magnet-primary");
|
||||
this.result = torrentInstance
|
||||
? { source: "torrent", torrentInstance }
|
||||
: this.result;
|
||||
return this.result;
|
||||
}
|
||||
|
||||
const message = this.magnetProvided && !this.magnetForPlayback
|
||||
? unsupportedBtihMessage
|
||||
: "No playable source found.";
|
||||
const message =
|
||||
this.magnetProvided && !this.magnetForPlayback
|
||||
? unsupportedBtihMessage
|
||||
: "No playable source found.";
|
||||
this.emit("status", { message });
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: null });
|
||||
this.emit("sourcechange", { source: null });
|
||||
@@ -848,9 +923,10 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
return this.result;
|
||||
} catch (error) {
|
||||
this.service.log("Error in playVideoWithFallback:", error);
|
||||
const message = error && error.message
|
||||
? `Playback error: ${error.message}`
|
||||
: "Playback error";
|
||||
const message =
|
||||
error && error.message
|
||||
? `Playback error: ${error.message}`
|
||||
: "Playback error";
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
|
||||
@@ -1776,6 +1776,9 @@ export class ProfileModalController {
|
||||
this.storagePrefixWarning = document.getElementById("storagePrefixWarning") || null;
|
||||
this.storageDefaultInput = document.getElementById("storageDefault") || null;
|
||||
this.storageR2Helper = document.getElementById("storageR2Helper") || null;
|
||||
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
|
||||
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
|
||||
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
|
||||
|
||||
// Backwards-compatible aliases retained for application code that still
|
||||
// mirrors DOM references from the controller. These should be removed once
|
||||
@@ -9042,8 +9045,8 @@ export class ProfileModalController {
|
||||
|
||||
fillStorageForm(conn) {
|
||||
if (!conn) return;
|
||||
const { provider, accessKeyId, secretAccessKey, accountId: payloadAccountId, endpoint: payloadEndpoint } = conn;
|
||||
const { endpoint, region, bucket, prefix, defaultForUploads, accountId } = conn.meta || {};
|
||||
const { provider, accessKeyId, secretAccessKey, accountId: payloadAccountId, endpoint: payloadEndpoint, forcePathStyle: payloadForcePathStyle } = conn;
|
||||
const { endpoint, region, bucket, prefix, defaultForUploads, accountId, forcePathStyle: metaForcePathStyle } = conn.meta || {};
|
||||
|
||||
if (this.storageProviderInput) this.storageProviderInput.value = provider || "cloudflare_r2";
|
||||
|
||||
@@ -9058,6 +9061,17 @@ export class ProfileModalController {
|
||||
if (this.storagePrefixInput) this.storagePrefixInput.value = prefix || "";
|
||||
if (this.storageDefaultInput) this.storageDefaultInput.checked = !!defaultForUploads;
|
||||
|
||||
if (this.storageForcePathStyleInput) {
|
||||
if (typeof payloadForcePathStyle === "boolean") {
|
||||
this.storageForcePathStyleInput.checked = payloadForcePathStyle;
|
||||
} else if (typeof metaForcePathStyle === "boolean") {
|
||||
this.storageForcePathStyleInput.checked = metaForcePathStyle;
|
||||
} else {
|
||||
// Default to true for S3 if unspecified
|
||||
this.storageForcePathStyleInput.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateStorageFormVisibility();
|
||||
this.handlePublicUrlInput();
|
||||
}
|
||||
@@ -9098,6 +9112,20 @@ export class ProfileModalController {
|
||||
if (isR2) this.storageR2Helper.classList.remove("hidden");
|
||||
else this.storageR2Helper.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.storageS3Helper) {
|
||||
if (!isR2) this.storageS3Helper.classList.remove("hidden");
|
||||
else this.storageS3Helper.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.storageForcePathStyleLabel) {
|
||||
if (!isR2) this.storageForcePathStyleLabel.classList.remove("hidden", "flex");
|
||||
else this.storageForcePathStyleLabel.classList.add("hidden");
|
||||
|
||||
if (!isR2) {
|
||||
this.storageForcePathStyleLabel.classList.add("flex");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleUnlockStorage() {
|
||||
@@ -9195,7 +9223,11 @@ export class ProfileModalController {
|
||||
}
|
||||
|
||||
let publicBaseUrl = "";
|
||||
let forcePathStyle = provider === PROVIDERS.GENERIC;
|
||||
// For Generic S3, respect the user's checkbox selection.
|
||||
let forcePathStyle = false;
|
||||
if (provider === PROVIDERS.GENERIC) {
|
||||
forcePathStyle = this.storageForcePathStyleInput?.checked ?? true;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
provider,
|
||||
@@ -9287,7 +9319,12 @@ export class ProfileModalController {
|
||||
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
|
||||
const secretAccessKey = this.storageSecretKeyInput?.value?.trim() || "";
|
||||
const bucket = this.storageBucketInput?.value?.trim() || "";
|
||||
const forcePathStyle = provider === PROVIDERS.GENERIC;
|
||||
|
||||
// Checkbox is only relevant for Generic S3
|
||||
const forcePathStyle = provider === PROVIDERS.GENERIC
|
||||
? (this.storageForcePathStyleInput?.checked ?? true)
|
||||
: false;
|
||||
|
||||
const publicBaseUrl = this.storagePrefixInput?.value?.trim() || "";
|
||||
|
||||
if (!accessKeyId || !secretAccessKey || !endpointOrAccount) {
|
||||
@@ -9348,6 +9385,9 @@ export class ProfileModalController {
|
||||
this.storagePrefixInput.value = "";
|
||||
this.storageDefaultInput.checked = false;
|
||||
this.storageProviderInput.value = "cloudflare_r2";
|
||||
if (this.storageForcePathStyleInput) {
|
||||
this.storageForcePathStyleInput.checked = true;
|
||||
}
|
||||
this.updateStorageFormVisibility();
|
||||
this.setStorageFormStatus("", "info");
|
||||
}
|
||||
|
||||
Generated
+460
@@ -16,6 +16,7 @@
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"c8": "^10.1.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.12",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
@@ -83,6 +84,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/memory": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz",
|
||||
@@ -762,6 +773,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1021,6 +1042,13 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -1260,6 +1288,40 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
||||
"integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"find-up": "^5.0.0",
|
||||
"foreground-child": "^3.1.1",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.1.6",
|
||||
"test-exclude": "^7.0.1",
|
||||
"v8-to-istanbul": "^9.0.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"c8": "bin/c8.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monocart-coverage-reports": "^2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"monocart-coverage-reports": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz",
|
||||
@@ -1367,6 +1429,100 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1417,6 +1573,13 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
|
||||
@@ -1889,6 +2052,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.20.tgz",
|
||||
@@ -1981,6 +2161,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -2231,6 +2421,13 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-tags": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
|
||||
@@ -2442,6 +2639,45 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
@@ -2590,6 +2826,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.truncate": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
|
||||
@@ -2604,6 +2856,22 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2831,6 +3099,38 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -2883,6 +3183,16 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -3427,6 +3737,16 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -3537,6 +3857,19 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -4063,6 +4396,21 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
|
||||
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^10.4.1",
|
||||
"minimatch": "^9.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -4194,6 +4542,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.12",
|
||||
"@types/istanbul-lib-coverage": "^2.0.1",
|
||||
"convert-source-map": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
@@ -4420,6 +4783,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
@@ -4432,6 +4805,93 @@
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"c8": "^10.1.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.12",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
|
||||
@@ -1,179 +1,75 @@
|
||||
import { decryptDM } from '../../js/dmDecryptor.js';
|
||||
import {
|
||||
fuzzBoolean,
|
||||
fuzzInt,
|
||||
fuzzString,
|
||||
fuzzHexString,
|
||||
fuzzJSON,
|
||||
pickOne,
|
||||
fuzzSurrogatePairString,
|
||||
saveFuzzReport,
|
||||
saveReproducer
|
||||
} from './fuzz-utils.mjs';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ITERATIONS = 1000;
|
||||
const FINDINGS = [];
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { runFuzzer, rng } from "./fuzz-shared.mjs";
|
||||
|
||||
function saveReproducerDM(id, input, error) {
|
||||
const reproDir = path.join('examples', 'reproducers', `fuzz-dmDecryptor-${id}`);
|
||||
if (!fs.existsSync(reproDir)) {
|
||||
fs.mkdirSync(reproDir, { recursive: true });
|
||||
}
|
||||
const originalPath = "../../js/dmDecryptor.js";
|
||||
const tempPath = "./temp_dmDecryptor.mjs";
|
||||
|
||||
const readmeContent = `# Fuzz Reproducer: dmDecryptor - ${id}
|
||||
// Read original file
|
||||
const originalContent = fs.readFileSync(new URL(originalPath, import.meta.url), "utf-8");
|
||||
|
||||
## Error
|
||||
\`\`\`
|
||||
${error.stack || error.message}
|
||||
\`\`\`
|
||||
`;
|
||||
// Replace import
|
||||
// The original file has: import { normalizeActorKey } from "./nostr/watchHistory.js";
|
||||
// We want to point to ./mocks/watchHistory.js
|
||||
const patchedContent = originalContent.replace(
|
||||
/import \{ normalizeActorKey \} from "\.\/nostr\/watchHistory\.js";/,
|
||||
'import { normalizeActorKey } from "./mocks/watchHistory.js";'
|
||||
);
|
||||
|
||||
fs.writeFileSync(path.join(reproDir, 'README.md'), readmeContent);
|
||||
// Write temp file
|
||||
fs.writeFileSync(new URL(tempPath, import.meta.url), patchedContent);
|
||||
|
||||
// Serialize input parts
|
||||
const event = input.event;
|
||||
const context = input.context;
|
||||
// Import the patched module
|
||||
const DmDecryptor = await import(tempPath);
|
||||
|
||||
// We can't easily capture the 'shouldFail' state unless we stored it on the object,
|
||||
// but we can infer or just log it. For now, let's just save the structure.
|
||||
|
||||
const serializedContext = {
|
||||
actorPubkey: context?.actorPubkey,
|
||||
decryptorConfigs: context?.decryptors && Array.isArray(context.decryptors) ? context.decryptors.map(d => ({
|
||||
scheme: d.scheme,
|
||||
supportsGiftWrap: d.supportsGiftWrap,
|
||||
})) : context?.decryptors
|
||||
async function fuzzTest(iteration) {
|
||||
const genEvent = () => {
|
||||
return {
|
||||
kind: rng.oneOf([4, 1059, rng.int(0, 20000)]),
|
||||
pubkey: rng.mixedString(64),
|
||||
created_at: rng.int(0, 2000000000),
|
||||
content: rng.mixedString(100),
|
||||
tags: rng.array(() => rng.array(() => rng.mixedString(20), 5), 5)
|
||||
};
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(reproDir, 'input-event.json'), JSON.stringify(event, null, 2));
|
||||
fs.writeFileSync(path.join(reproDir, 'input-context-config.json'), JSON.stringify(serializedContext, null, 2));
|
||||
const genDecryptor = () => {
|
||||
return {
|
||||
scheme: rng.oneOf(["nip04", "nip44", "nip44_v2", rng.mixedString(10)]),
|
||||
decrypt: async (pubkey, ciphertext) => {
|
||||
if (rng.bool()) {
|
||||
throw new Error("Decryption failed " + rng.mixedString(10));
|
||||
}
|
||||
// Return either a string (valid) or garbage
|
||||
if (rng.bool()) {
|
||||
return JSON.stringify({ content: "decrypted message" });
|
||||
}
|
||||
return rng.nastyString();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const scriptContent = `
|
||||
import { decryptDM } from '../../../../js/dmDecryptor.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
const event = rng.bool() ? genEvent() : rng.nastyString();
|
||||
const context = {
|
||||
actorPubkey: rng.mixedString(64),
|
||||
decryptors: rng.array(genDecryptor, 3)
|
||||
};
|
||||
|
||||
const event = JSON.parse(fs.readFileSync('input-event.json', 'utf8'));
|
||||
const contextConfig = JSON.parse(fs.readFileSync('input-context-config.json', 'utf8'));
|
||||
// Run the target
|
||||
await DmDecryptor.decryptDM(event, context);
|
||||
|
||||
// Reconstruct decryptors
|
||||
const decryptors = Array.isArray(contextConfig.decryptorConfigs)
|
||||
? contextConfig.decryptorConfigs.map(config => ({
|
||||
scheme: config.scheme,
|
||||
supportsGiftWrap: config.supportsGiftWrap,
|
||||
decrypt: async (pubkey, ciphertext) => {
|
||||
console.log('Mock decrypt called for:', pubkey);
|
||||
return "mock-decrypted-content";
|
||||
}
|
||||
}))
|
||||
: contextConfig.decryptorConfigs;
|
||||
|
||||
const context = {
|
||||
actorPubkey: contextConfig.actorPubkey,
|
||||
decryptors
|
||||
};
|
||||
|
||||
console.log('Running reproduction...');
|
||||
try {
|
||||
await decryptDM(event, context);
|
||||
console.log('Execution finished without crash.');
|
||||
} catch (error) {
|
||||
console.error('Crash reproduced:', error);
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(reproDir, 'repro.mjs'), scriptContent);
|
||||
console.log(`Reproducer saved to ${reproDir}`);
|
||||
return { event, context };
|
||||
}
|
||||
|
||||
function logError(target, input, error) {
|
||||
const id = Date.now().toString() + Math.floor(Math.random() * 1000);
|
||||
console.error(`[FAIL] ${target} crashed! ID: ${id}`);
|
||||
FINDINGS.push({
|
||||
id,
|
||||
target,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
input
|
||||
runFuzzer("dmDecryptor", 5000, fuzzTest)
|
||||
.catch(err => {
|
||||
console.error("Fatal fuzzer error:", err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
// Cleanup
|
||||
if (fs.existsSync(new URL(tempPath, import.meta.url))) {
|
||||
fs.unlinkSync(new URL(tempPath, import.meta.url));
|
||||
}
|
||||
});
|
||||
saveReproducerDM(id, input, error);
|
||||
}
|
||||
|
||||
function mockDecryptor(shouldFail = false) {
|
||||
return {
|
||||
scheme: pickOne(['nip04', 'nip44', 'nip44_v2', fuzzString()]),
|
||||
decrypt: async (pubkey, ciphertext) => {
|
||||
if (shouldFail) {
|
||||
throw new Error('Mock Decryption Failed');
|
||||
}
|
||||
// Return a valid stringified JSON event sometimes, or random garbage
|
||||
if (fuzzBoolean()) {
|
||||
return JSON.stringify({
|
||||
content: fuzzString(20),
|
||||
pubkey: fuzzHexString(),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: []
|
||||
});
|
||||
}
|
||||
return fuzzString(50);
|
||||
},
|
||||
supportsGiftWrap: fuzzBoolean()
|
||||
};
|
||||
}
|
||||
|
||||
function generateRandomEvent() {
|
||||
const kind = pickOne([4, 1059, fuzzInt(0, 20000)]);
|
||||
const content = fuzzBoolean() ? fuzzString(100) : fuzzJSON(2);
|
||||
|
||||
return {
|
||||
kind,
|
||||
pubkey: fuzzHexString(),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: fuzzBoolean() ? [] : [['p', fuzzHexString()], ['encrypted', 'nip44']],
|
||||
content: typeof content === 'string' ? content : JSON.stringify(content)
|
||||
};
|
||||
}
|
||||
|
||||
function generateRandomContext() {
|
||||
const decryptors = [];
|
||||
const count = fuzzInt(0, 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
decryptors.push(mockDecryptor(fuzzBoolean())); // some will fail
|
||||
}
|
||||
|
||||
return {
|
||||
actorPubkey: fuzzHexString(),
|
||||
decryptors
|
||||
};
|
||||
}
|
||||
|
||||
async function fuzzDecryptor() {
|
||||
console.log('Starting fuzzing for dmDecryptor.js...');
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const event = generateRandomEvent();
|
||||
const context = generateRandomContext();
|
||||
|
||||
// Also test null/undefined/garbage inputs
|
||||
const inputEvent = pickOne([event, null, undefined, "garbage", {}]);
|
||||
const inputContext = pickOne([context, null, undefined, {}, { decryptors: "garbage" }]);
|
||||
|
||||
try {
|
||||
await decryptDM(inputEvent, inputContext);
|
||||
} catch (error) {
|
||||
logError('decryptDM', { event: inputEvent, context: inputContext }, error);
|
||||
}
|
||||
}
|
||||
|
||||
saveFuzzReport('dmDecryptor', FINDINGS);
|
||||
console.log(`Fuzzing complete. Found ${FINDINGS.length} crashes.`);
|
||||
if (FINDINGS.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fuzzDecryptor().catch(err => {
|
||||
console.error('Fuzzing script failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,90 +1,43 @@
|
||||
import { normalizeAndAugmentMagnet } from '../../js/magnetUtils.js';
|
||||
import {
|
||||
fuzzBoolean,
|
||||
fuzzInt,
|
||||
fuzzString,
|
||||
fuzzHexString,
|
||||
fuzzJSON,
|
||||
pickOne,
|
||||
fuzzSurrogatePairString,
|
||||
saveFuzzReport,
|
||||
saveReproducer
|
||||
} from './fuzz-utils.mjs';
|
||||
|
||||
const ITERATIONS = 1000;
|
||||
const FINDINGS = [];
|
||||
import { runFuzzer, rng } from "./fuzz-shared.mjs";
|
||||
|
||||
function logError(target, input, error) {
|
||||
const id = Date.now().toString() + Math.floor(Math.random() * 1000);
|
||||
console.error(`[FAIL] ${target} crashed! ID: ${id}`);
|
||||
FINDINGS.push({
|
||||
id,
|
||||
target,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
input
|
||||
});
|
||||
saveReproducer(target, id, input, error);
|
||||
}
|
||||
|
||||
function generateRandomMagnet() {
|
||||
const scheme = 'magnet:?';
|
||||
const xt = `xt=urn:btih:${fuzzHexString(40)}`;
|
||||
const dn = `dn=${fuzzString(10)}`;
|
||||
const tr = `tr=${encodeURIComponent('wss://' + fuzzString(10) + '.com')}`;
|
||||
|
||||
// Mix valid parts with garbage
|
||||
const parts = [xt, dn, tr];
|
||||
if (fuzzBoolean()) parts.push(`ws=${encodeURIComponent('https://' + fuzzString(10) + '.com')}`);
|
||||
if (fuzzBoolean()) parts.push(fuzzString(5) + '=' + fuzzString(5));
|
||||
|
||||
// Shuffle
|
||||
parts.sort(() => Math.random() - 0.5);
|
||||
|
||||
return scheme + parts.join('&');
|
||||
}
|
||||
|
||||
function generateRandomOptions() {
|
||||
return {
|
||||
webSeed: fuzzBoolean() ? [fuzzString()] : fuzzString(),
|
||||
torrentUrl: fuzzBoolean() ? fuzzString() : undefined,
|
||||
xs: fuzzBoolean() ? fuzzString() : undefined,
|
||||
extraTrackers: fuzzBoolean() ? [fuzzString()] : [],
|
||||
appProtocol: pickOne(['http:', 'https:', fuzzString(), undefined]),
|
||||
logger: fuzzBoolean() ? () => {} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function fuzzMagnetUtils() {
|
||||
console.log('Starting fuzzing for magnetUtils.js...');
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
// Inputs: valid magnet, partial, garbage, huge string, percent encoded mess
|
||||
const rawValue = pickOne([
|
||||
generateRandomMagnet(),
|
||||
fuzzString(100),
|
||||
`magnet:?xt=${fuzzString(10)}`, // invalid xt
|
||||
fuzzSurrogatePairString(50),
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
123
|
||||
]);
|
||||
|
||||
const options = fuzzBoolean() ? generateRandomOptions() : undefined;
|
||||
|
||||
try {
|
||||
normalizeAndAugmentMagnet(rawValue, options);
|
||||
} catch (error) {
|
||||
logError('normalizeAndAugmentMagnet', { rawValue, options }, error);
|
||||
}
|
||||
// Polyfills
|
||||
global.window = {
|
||||
location: {
|
||||
protocol: "https:"
|
||||
}
|
||||
};
|
||||
|
||||
saveFuzzReport('magnetUtils', FINDINGS);
|
||||
console.log(`Fuzzing complete. Found ${FINDINGS.length} crashes.`);
|
||||
if (FINDINGS.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
// Import the target module
|
||||
// Note: We are in scripts/agent/
|
||||
import * as MagnetUtils from "../../js/magnetUtils.js";
|
||||
|
||||
async function fuzzTest(iteration) {
|
||||
// Randomize window protocol sometimes
|
||||
global.window.location.protocol = rng.bool() ? "https:" : "http:";
|
||||
|
||||
const rawValue = rng.mixedString(100);
|
||||
|
||||
const genOptions = () => {
|
||||
if (rng.bool()) return null;
|
||||
return {
|
||||
webSeed: rng.bool() ? rng.mixedString(50) : rng.array(() => rng.mixedString(50), 3),
|
||||
torrentUrl: rng.mixedString(50),
|
||||
xs: rng.mixedString(50),
|
||||
extraTrackers: rng.array(() => rng.mixedString(30), 5),
|
||||
logger: rng.bool() ? (() => {}) : null,
|
||||
appProtocol: rng.bool() ? rng.oneOf(["http:", "https:", rng.nastyString()]) : undefined
|
||||
};
|
||||
};
|
||||
|
||||
const options = genOptions();
|
||||
|
||||
MagnetUtils.normalizeAndAugmentMagnet(rawValue, options);
|
||||
|
||||
return { rawValue, options };
|
||||
}
|
||||
|
||||
fuzzMagnetUtils();
|
||||
runFuzzer("magnetUtils", 5000, fuzzTest).catch(err => {
|
||||
console.error("Fatal fuzzer error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,173 +1,126 @@
|
||||
import {
|
||||
buildVideoPostEvent,
|
||||
buildHttpAuthEvent,
|
||||
buildReportEvent,
|
||||
buildVideoMirrorEvent,
|
||||
buildRepostEvent,
|
||||
buildShareEvent,
|
||||
buildRelayListEvent,
|
||||
buildDmRelayListEvent,
|
||||
buildProfileMetadataEvent,
|
||||
buildMuteListEvent,
|
||||
buildDeletionEvent,
|
||||
buildLegacyDirectMessageEvent,
|
||||
buildDmAttachmentEvent,
|
||||
buildDmReadReceiptEvent,
|
||||
buildDmTypingIndicatorEvent,
|
||||
buildViewEvent,
|
||||
buildZapRequestEvent,
|
||||
buildReactionEvent,
|
||||
buildCommentEvent,
|
||||
buildWatchHistoryEvent,
|
||||
buildSubscriptionListEvent,
|
||||
buildBlockListEvent,
|
||||
buildHashtagPreferenceEvent,
|
||||
buildAdminListEvent,
|
||||
validateEventStructure,
|
||||
sanitizeAdditionalTags,
|
||||
NOTE_TYPES
|
||||
} from '../../js/nostrEventSchemas.js';
|
||||
|
||||
import {
|
||||
fuzzBoolean,
|
||||
fuzzInt,
|
||||
fuzzString,
|
||||
fuzzHexString,
|
||||
fuzzJSON,
|
||||
pickOne,
|
||||
fuzzSurrogatePairString,
|
||||
saveFuzzReport,
|
||||
saveReproducer
|
||||
} from './fuzz-utils.mjs';
|
||||
import { runFuzzer, rng } from "./fuzz-shared.mjs";
|
||||
import { TextEncoder, TextDecoder } from "util";
|
||||
|
||||
const ITERATIONS = 1000;
|
||||
const FINDINGS = [];
|
||||
// Polyfills
|
||||
global.TextEncoder = TextEncoder;
|
||||
global.TextDecoder = TextDecoder;
|
||||
global.window = {
|
||||
bitvidNostrEventOverrides: {}
|
||||
};
|
||||
|
||||
function logError(target, input, error) {
|
||||
const id = Date.now().toString() + Math.floor(Math.random() * 1000);
|
||||
console.error(`[FAIL] ${target} crashed! ID: ${id}`);
|
||||
FINDINGS.push({
|
||||
id,
|
||||
target,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
input
|
||||
});
|
||||
saveReproducer(target, id, input, error);
|
||||
}
|
||||
// Import the target module
|
||||
// Note: We are in scripts/agent/
|
||||
import * as Schemas from "../../js/nostrEventSchemas.js";
|
||||
|
||||
function generateRandomParams() {
|
||||
const params = {};
|
||||
const keys = ['pubkey', 'created_at', 'content', 'tags', 'additionalTags', 'dTagValue', 'url', 'method', 'payload', 'eventId', 'userId', 'reportType', 'relayHint', 'recipientPubkey', 'ciphertext', 'attachment', 'messageKind', 'expiresAt', 'pointerValue', 'pointerTag', 'dedupeTag', 'amountSats', 'lnurl', 'coordinate', 'videoEventId', 'rootKind'];
|
||||
async function fuzzTest(iteration) {
|
||||
// We choose a random function to fuzz in each iteration or fuzz all of them?
|
||||
// Let's pick one random target per iteration to keep it simple and isolate crashes.
|
||||
|
||||
// Randomly select some keys to populate
|
||||
keys.forEach(key => {
|
||||
if (fuzzBoolean()) {
|
||||
if (key === 'created_at' || key === 'amountSats' || key === 'messageKind' || key === 'expiresAt') {
|
||||
params[key] = fuzzBoolean() ? fuzzInt() : fuzzString(); // Mix types
|
||||
} else if (key === 'pubkey' || key === 'recipientPubkey') {
|
||||
params[key] = fuzzBoolean() ? fuzzHexString() : fuzzString();
|
||||
} else if (key === 'tags' || key === 'additionalTags') {
|
||||
params[key] = fuzzBoolean() ? [] : fuzzJSON(2); // Can be valid array or random JSON
|
||||
} else if (key === 'content') {
|
||||
params[key] = fuzzBoolean() ? fuzzSurrogatePairString(100) : fuzzJSON(2);
|
||||
} else {
|
||||
params[key] = fuzzString();
|
||||
}
|
||||
}
|
||||
});
|
||||
const targets = [
|
||||
"buildVideoPostEvent",
|
||||
"buildViewEvent",
|
||||
"buildCommentEvent",
|
||||
"buildReactionEvent",
|
||||
"buildZapRequestEvent",
|
||||
"sanitizeAdditionalTags",
|
||||
"validateEventStructure"
|
||||
];
|
||||
|
||||
return params;
|
||||
}
|
||||
const targetName = rng.oneOf(targets);
|
||||
let inputs = {};
|
||||
|
||||
function fuzzBuilder(name, builderFn, argsGenerator) {
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const args = argsGenerator();
|
||||
try {
|
||||
if (Array.isArray(args)) {
|
||||
builderFn(...args);
|
||||
} else {
|
||||
builderFn(args);
|
||||
}
|
||||
} catch (error) {
|
||||
logError(name, args, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Helper to generate a comprehensive random params object
|
||||
const genParams = () => {
|
||||
// 50% chance of being undefined/null/non-object to test robustness
|
||||
if (Math.random() < 0.1) return rng.nastyString();
|
||||
if (Math.random() < 0.1) return null;
|
||||
|
||||
console.log('Starting fuzzing for nostrEventSchemas.js...');
|
||||
// Otherwise generate a rich object
|
||||
const params = {
|
||||
pubkey: rng.mixedString(64),
|
||||
created_at: rng.bool() ? rng.int(0, 2000000000) : rng.nastyString(),
|
||||
content: rng.bool() ? rng.mixedString(1000) : rng.recursiveObject(2),
|
||||
additionalTags: rng.bool() ? rng.array(() => rng.array(() => rng.mixedString(20), 5), 10) : rng.nastyString(),
|
||||
};
|
||||
|
||||
// Fuzz all builders
|
||||
const builders = [
|
||||
{ name: 'buildVideoPostEvent', fn: buildVideoPostEvent },
|
||||
{ name: 'buildHttpAuthEvent', fn: buildHttpAuthEvent },
|
||||
{ name: 'buildReportEvent', fn: buildReportEvent },
|
||||
{ name: 'buildVideoMirrorEvent', fn: buildVideoMirrorEvent },
|
||||
{ name: 'buildRepostEvent', fn: buildRepostEvent },
|
||||
{ name: 'buildShareEvent', fn: buildShareEvent },
|
||||
{ name: 'buildRelayListEvent', fn: buildRelayListEvent },
|
||||
{ name: 'buildDmRelayListEvent', fn: buildDmRelayListEvent },
|
||||
{ name: 'buildProfileMetadataEvent', fn: buildProfileMetadataEvent },
|
||||
{ name: 'buildMuteListEvent', fn: buildMuteListEvent },
|
||||
{ name: 'buildDeletionEvent', fn: buildDeletionEvent },
|
||||
{ name: 'buildLegacyDirectMessageEvent', fn: buildLegacyDirectMessageEvent },
|
||||
{ name: 'buildDmAttachmentEvent', fn: buildDmAttachmentEvent },
|
||||
{ name: 'buildDmReadReceiptEvent', fn: buildDmReadReceiptEvent },
|
||||
{ name: 'buildDmTypingIndicatorEvent', fn: buildDmTypingIndicatorEvent },
|
||||
{ name: 'buildViewEvent', fn: buildViewEvent },
|
||||
{ name: 'buildZapRequestEvent', fn: buildZapRequestEvent },
|
||||
{ name: 'buildReactionEvent', fn: buildReactionEvent },
|
||||
{ name: 'buildCommentEvent', fn: buildCommentEvent },
|
||||
{ name: 'buildWatchHistoryEvent', fn: buildWatchHistoryEvent },
|
||||
{ name: 'buildSubscriptionListEvent', fn: buildSubscriptionListEvent },
|
||||
{ name: 'buildBlockListEvent', fn: buildBlockListEvent },
|
||||
{ name: 'buildHashtagPreferenceEvent', fn: buildHashtagPreferenceEvent },
|
||||
];
|
||||
// Add specific fields that might be used by specific builders
|
||||
const extraFields = [
|
||||
"dTagValue", "url", "magnet", "thumbnail", "description", "mode", "videoRootId",
|
||||
"pointerValue", "pointerTag", "pointerTags", "dedupeTag", "includeSessionTag",
|
||||
"videoEventId", "videoEventRelay", "videoDefinitionAddress", "rootIdentifier",
|
||||
"parentCommentId", "rootKind", "rootAuthorPubkey", "parentKind",
|
||||
"recipientPubkey", "relays", "amountSats", "lnurl", "eventId", "coordinate",
|
||||
"targetPointer", "targetAuthorPubkey"
|
||||
];
|
||||
|
||||
builders.forEach(({ name, fn }) => {
|
||||
fuzzBuilder(name, fn, () => [generateRandomParams()]);
|
||||
});
|
||||
|
||||
// Special case for buildAdminListEvent
|
||||
fuzzBuilder('buildAdminListEvent', buildAdminListEvent, () => {
|
||||
const listKey = pickOne(['moderation', 'editors', 'whitelist', 'blacklist', fuzzString()]);
|
||||
return [listKey, generateRandomParams()];
|
||||
});
|
||||
|
||||
// Fuzz validateEventStructure
|
||||
fuzzBuilder('validateEventStructure', validateEventStructure, () => {
|
||||
const type = pickOne([...Object.values(NOTE_TYPES), fuzzString()]);
|
||||
const event = fuzzJSON(2); // Random object/JSON
|
||||
return [type, event];
|
||||
});
|
||||
|
||||
// Fuzz sanitizeAdditionalTags
|
||||
fuzzBuilder('sanitizeAdditionalTags', sanitizeAdditionalTags, () => {
|
||||
// Random input: array of arrays, array of strings, random object, etc.
|
||||
if (fuzzBoolean()) {
|
||||
// structured but potentially malformed tags
|
||||
const tags = [];
|
||||
const count = fuzzInt(0, 10);
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (fuzzBoolean()) {
|
||||
const tag = [fuzzString()];
|
||||
const args = fuzzInt(0, 5);
|
||||
for (let j = 0; j < args; j++) {
|
||||
tag.push(fuzzBoolean() ? fuzzString() : fuzzJSON(1));
|
||||
extraFields.forEach(field => {
|
||||
if (rng.bool()) {
|
||||
params[field] = rng.mixedString(50);
|
||||
} else if (rng.bool()) {
|
||||
params[field] = rng.recursiveObject(1); // Potentially invalid type
|
||||
}
|
||||
tags.push(tag);
|
||||
} else {
|
||||
tags.push(pickOne([null, undefined, 123, {}, "string"]));
|
||||
}
|
||||
}
|
||||
return [tags];
|
||||
}
|
||||
// completely random input
|
||||
return [fuzzJSON(2)];
|
||||
});
|
||||
});
|
||||
|
||||
saveFuzzReport('nostrEventSchemas', FINDINGS);
|
||||
console.log(`Fuzzing complete. Found ${FINDINGS.length} crashes.`);
|
||||
if (FINDINGS.length > 0) {
|
||||
process.exit(1);
|
||||
return params;
|
||||
};
|
||||
|
||||
switch (targetName) {
|
||||
case "buildVideoPostEvent":
|
||||
inputs = genParams();
|
||||
Schemas.buildVideoPostEvent(inputs);
|
||||
break;
|
||||
|
||||
case "buildViewEvent":
|
||||
inputs = genParams();
|
||||
Schemas.buildViewEvent(inputs);
|
||||
break;
|
||||
|
||||
case "buildCommentEvent":
|
||||
inputs = genParams();
|
||||
Schemas.buildCommentEvent(inputs);
|
||||
break;
|
||||
|
||||
case "buildReactionEvent":
|
||||
inputs = genParams();
|
||||
Schemas.buildReactionEvent(inputs);
|
||||
break;
|
||||
|
||||
case "buildZapRequestEvent":
|
||||
inputs = genParams();
|
||||
Schemas.buildZapRequestEvent(inputs);
|
||||
break;
|
||||
|
||||
case "sanitizeAdditionalTags":
|
||||
// sanitizeAdditionalTags expects an array of tags (arrays)
|
||||
if (rng.bool()) {
|
||||
inputs = rng.array(() => rng.array(() => rng.mixedString(20), 5), 20);
|
||||
} else {
|
||||
inputs = rng.nastyString(); // or object, etc.
|
||||
}
|
||||
Schemas.sanitizeAdditionalTags(inputs);
|
||||
break;
|
||||
|
||||
case "validateEventStructure":
|
||||
const type = rng.oneOf(Object.values(Schemas.NOTE_TYPES));
|
||||
let event = genParams(); // repurpose genParams to make a fake event
|
||||
// Ensure event is an object for this specific test setup, unless we want to fuzz that too.
|
||||
// validateEventStructure checks: if (!event || typeof event !== "object") return { valid: false ... }
|
||||
// So passing a string is valid input for the function (should not crash),
|
||||
// but my fuzzer crashes trying to assign .kind to it.
|
||||
if (event && typeof event === 'object') {
|
||||
event.kind = rng.int(0, 50000);
|
||||
event.tags = rng.array(() => rng.array(() => rng.mixedString(10), 3), 5);
|
||||
}
|
||||
inputs = { type, event };
|
||||
Schemas.validateEventStructure(type, event);
|
||||
break;
|
||||
}
|
||||
|
||||
return { target: targetName, inputs };
|
||||
}
|
||||
|
||||
runFuzzer("nostrEventSchemas", 5000, fuzzTest).catch(err => {
|
||||
console.error("Fatal fuzzer error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
+149
-95
@@ -5,133 +5,187 @@ import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPRODUCERS_DIR = path.resolve(__dirname, "../../examples/reproducers");
|
||||
const ARTIFACTS_DIR = path.resolve(__dirname, "../../artifacts");
|
||||
|
||||
const REPRODUCER_DIR = path.join(process.cwd(), "examples/reproducers");
|
||||
const ARTIFACTS_DIR = path.join(process.cwd(), "artifacts");
|
||||
|
||||
// Ensure directories exist
|
||||
if (!fs.existsSync(REPRODUCERS_DIR)) {
|
||||
fs.mkdirSync(REPRODUCERS_DIR, { recursive: true });
|
||||
if (!fs.existsSync(REPRODUCER_DIR)) {
|
||||
fs.mkdirSync(REPRODUCER_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) {
|
||||
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
export const rng = {
|
||||
bool: () => Math.random() > 0.5,
|
||||
int: (min = 0, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min,
|
||||
float: (min = 0, max = 100) => Math.random() * (max - min) + min,
|
||||
oneOf: (arr) => arr[Math.floor(Math.random() * arr.length)],
|
||||
|
||||
export function randomString(length) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}|:<>?~`-=[]\\;',./";
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function randomHex(length) {
|
||||
const chars = "0123456789abcdef";
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function randomBoolean() {
|
||||
return Math.random() < 0.5;
|
||||
}
|
||||
|
||||
export function randomItem(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
export function randomJSON(depth = 3, maxFields = 5) {
|
||||
if (depth <= 0) {
|
||||
const type = randomInt(0, 3);
|
||||
if (type === 0) return randomString(10);
|
||||
if (type === 1) return randomInt(-1000, 1000);
|
||||
if (type === 2) return randomBoolean();
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = randomInt(0, 5);
|
||||
if (type === 0) {
|
||||
// Array
|
||||
const arr = [];
|
||||
const len = randomInt(0, maxFields);
|
||||
string: (maxLength = 100) => {
|
||||
const len = Math.floor(Math.random() * maxLength);
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
arr.push(randomJSON(depth - 1, maxFields));
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return arr;
|
||||
} else if (type === 1) {
|
||||
// Object
|
||||
const obj = {};
|
||||
const len = randomInt(0, maxFields);
|
||||
return result;
|
||||
},
|
||||
|
||||
nastyString: () => {
|
||||
const candidates = [
|
||||
"",
|
||||
"null",
|
||||
"undefined",
|
||||
"NaN",
|
||||
"\x00",
|
||||
"\n",
|
||||
"\r",
|
||||
"\t",
|
||||
" ",
|
||||
" ",
|
||||
"https://example.com",
|
||||
"wss://relay.example.com",
|
||||
"<script>alert(1)</script>",
|
||||
"' OR 1=1 --",
|
||||
"javascript:void(0)",
|
||||
"data:text/plain;base64,SGVsbG8=",
|
||||
"😀",
|
||||
"ñ",
|
||||
"\uD83D\uDCA9", // Pile of poo
|
||||
"\uD800", // Lone surrogate (high)
|
||||
"\uDFFF", // Lone surrogate (low)
|
||||
"A".repeat(1000), // Long string
|
||||
"A".repeat(10000), // Very long string
|
||||
JSON.stringify({ foo: "bar" }),
|
||||
"{}",
|
||||
"[]"
|
||||
];
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
},
|
||||
|
||||
mixedString: (maxLength = 100) => {
|
||||
if (Math.random() < 0.3) return rng.nastyString();
|
||||
return rng.string(maxLength);
|
||||
},
|
||||
|
||||
array: (generator, maxLen = 10) => {
|
||||
const len = rng.int(0, maxLen);
|
||||
const result = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
obj[randomString(5)] = randomJSON(depth - 1, maxFields);
|
||||
result.push(generator());
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
object: (keyGen, valGen, maxKeys = 10) => {
|
||||
const len = rng.int(0, maxKeys);
|
||||
const result = {};
|
||||
for (let i = 0; i < len; i++) {
|
||||
result[keyGen()] = valGen();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
recursiveObject: (depth = 3) => {
|
||||
if (depth <= 0) return rng.mixedString();
|
||||
|
||||
const type = rng.int(0, 4);
|
||||
if (type === 0) return rng.int(-1000, 1000);
|
||||
if (type === 1) return rng.mixedString();
|
||||
if (type === 2) return rng.bool();
|
||||
if (type === 3) return null;
|
||||
|
||||
if (type === 4) {
|
||||
// Array
|
||||
const len = rng.int(0, 5);
|
||||
const arr = [];
|
||||
for(let i=0; i<len; i++) arr.push(rng.recursiveObject(depth - 1));
|
||||
return arr;
|
||||
}
|
||||
|
||||
// Object
|
||||
const len = rng.int(0, 5);
|
||||
const obj = {};
|
||||
for(let i=0; i<len; i++) {
|
||||
obj[rng.string(10)] = rng.recursiveObject(depth - 1);
|
||||
}
|
||||
return obj;
|
||||
} else {
|
||||
// Scalar
|
||||
return randomJSON(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function saveReproducer(target, input, error) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `crash-${target}-${timestamp}.json`;
|
||||
const filepath = path.join(REPRODUCERS_DIR, filename);
|
||||
export function saveReproducer(targetName, input, error) {
|
||||
const timestamp = Date.now();
|
||||
const filename = `fuzz-${targetName}-${timestamp}.json`;
|
||||
const filepath = path.join(REPRODUCER_DIR, filename);
|
||||
|
||||
const report = {
|
||||
target,
|
||||
const content = {
|
||||
target: targetName,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
name: error.name
|
||||
},
|
||||
input
|
||||
};
|
||||
|
||||
fs.writeFileSync(filepath, JSON.stringify(report, null, 2));
|
||||
fs.writeFileSync(filepath, JSON.stringify(content, null, 2));
|
||||
console.error(`[FUZZ] Saved reproducer to ${filepath}`);
|
||||
return filename;
|
||||
return filepath;
|
||||
}
|
||||
|
||||
export async function runFuzzer(name, fuzzFn, iterations = 1000) {
|
||||
console.log(`[FUZZ] Starting fuzzer for ${name} with ${iterations} iterations...`);
|
||||
const crashes = [];
|
||||
const start = Date.now();
|
||||
export function saveReport(targetName, stats) {
|
||||
const filepath = path.join(ARTIFACTS_DIR, `fuzz-report-${targetName}.json`);
|
||||
fs.writeFileSync(filepath, JSON.stringify(stats, null, 2));
|
||||
console.log(`[FUZZ] Saved report to ${filepath}`);
|
||||
}
|
||||
|
||||
export async function runFuzzer(targetName, iterations, testFn) {
|
||||
console.log(`[FUZZ] Starting fuzzer for ${targetName} with ${iterations} iterations...`);
|
||||
|
||||
const stats = {
|
||||
target: targetName,
|
||||
iterations: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
crashes: [], // List of { input, error, reproducerPath }
|
||||
startTime: Date.now(),
|
||||
endTime: null
|
||||
};
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (i % 100 === 0) process.stdout.write(".");
|
||||
let currentInput = null;
|
||||
stats.iterations++;
|
||||
let input = null;
|
||||
|
||||
try {
|
||||
const input = await fuzzFn(i);
|
||||
currentInput = input;
|
||||
} catch (error) {
|
||||
process.stdout.write("E");
|
||||
const filename = saveReproducer(name, currentInput, error);
|
||||
crashes.push({
|
||||
input = await testFn(i);
|
||||
stats.passed++;
|
||||
} catch (err) {
|
||||
stats.failed++;
|
||||
const reproducerPath = saveReproducer(targetName, input, err);
|
||||
stats.crashes.push({
|
||||
iteration: i,
|
||||
error: error.message,
|
||||
reproducer: filename
|
||||
error: err.message,
|
||||
reproducerPath
|
||||
});
|
||||
// Optionally stop on first crash or continue
|
||||
// For now, we continue but log it.
|
||||
}
|
||||
|
||||
if (i % 100 === 0 && i > 0) {
|
||||
process.stdout.write(".");
|
||||
}
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
|
||||
console.log("\n");
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
console.log(`[FUZZ] Finished ${name} in ${duration.toFixed(2)}s. Crashes: ${crashes.length}`);
|
||||
stats.endTime = Date.now();
|
||||
stats.duration = stats.endTime - stats.startTime;
|
||||
|
||||
const reportPath = path.join(ARTIFACTS_DIR, `fuzz-report-${name}.json`);
|
||||
const report = {
|
||||
target: name,
|
||||
iterations,
|
||||
duration,
|
||||
crashes
|
||||
};
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
console.log(`[FUZZ] Saved report to ${reportPath}`);
|
||||
console.log(`[FUZZ] Finished. ${stats.passed} passed, ${stats.failed} failed.`);
|
||||
saveReport(targetName, stats);
|
||||
|
||||
return stats;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export function normalizeActorKey(key) {
|
||||
if (typeof key !== 'string') return '';
|
||||
// simple hex check or return as is for mocking
|
||||
return key.toLowerCase().trim();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { NostrClient } from "../../js/nostr/client.js";
|
||||
|
||||
// Mock global objects needed for NostrClient
|
||||
if (!globalThis.WebSocket) {
|
||||
globalThis.WebSocket = class MockWebSocket {};
|
||||
}
|
||||
|
||||
describe("NostrClient", () => {
|
||||
let client;
|
||||
let mockPool;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new NostrClient();
|
||||
|
||||
// Mock SimplePool
|
||||
mockPool = {
|
||||
list: mock.fn(async () => []),
|
||||
get: mock.fn(async () => null),
|
||||
publish: mock.fn(async () => []),
|
||||
sub: mock.fn(() => ({
|
||||
on: mock.fn(),
|
||||
unsub: mock.fn(),
|
||||
})),
|
||||
ensureRelay: mock.fn(async () => ({
|
||||
send: mock.fn(),
|
||||
close: mock.fn(),
|
||||
})),
|
||||
};
|
||||
|
||||
// Inject mock pool
|
||||
client.pool = mockPool;
|
||||
client.isInitialized = true; // Skip init network calls
|
||||
client.relays = ["wss://relay.example.com"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("Initialization", () => {
|
||||
it("should initialize with default state", () => {
|
||||
const newClient = new NostrClient();
|
||||
assert.equal(newClient.isInitialized, false);
|
||||
assert.ok(newClient.allEvents instanceof Map);
|
||||
assert.ok(newClient.activeMap instanceof Map);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchListIncrementally", () => {
|
||||
it("should fetch events and deduplicate results", async () => {
|
||||
const mockEvents = [
|
||||
{ id: "1", created_at: 100, kind: 1, pubkey: "abc" },
|
||||
{ id: "2", created_at: 200, kind: 1, pubkey: "abc" },
|
||||
];
|
||||
|
||||
// Setup mock to return events
|
||||
mockPool.list.mock.mockImplementation(async () => mockEvents);
|
||||
|
||||
const events = await client.fetchListIncrementally({
|
||||
kind: 1,
|
||||
pubkey: "abc"
|
||||
});
|
||||
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events[0].id, "1");
|
||||
assert.equal(events[1].id, "2");
|
||||
assert.equal(mockPool.list.mock.callCount(), 1); // 1 call for 1 relay
|
||||
});
|
||||
|
||||
it("should handle incremental updates using lastSeen", async () => {
|
||||
// Mock syncMetadataStore
|
||||
client.syncMetadataStore.getLastSeen = () => 100;
|
||||
|
||||
await client.fetchListIncrementally({
|
||||
kind: 1,
|
||||
pubkey: "abc",
|
||||
relayUrls: ["wss://relay.example.com"]
|
||||
});
|
||||
|
||||
// Verify filter included 'since'
|
||||
const call = mockPool.list.mock.calls[0];
|
||||
const filter = call.arguments[1];
|
||||
// pool.list is called with ([url], [filter]), so arguments[1] is an array
|
||||
assert.equal(filter[0].since, 101);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeVideos", () => {
|
||||
it("should subscribe to video events and buffer them", async () => {
|
||||
// Create a sub mock that we can control
|
||||
const subMock = {
|
||||
on: (type, cb) => {
|
||||
if (type === "event") {
|
||||
// Simulate incoming event immediately
|
||||
cb({
|
||||
id: "v1",
|
||||
kind: 30078,
|
||||
pubkey: "pub1",
|
||||
created_at: 1000,
|
||||
content: JSON.stringify({
|
||||
title: "Test Video",
|
||||
videoRootId: "root1",
|
||||
url: "https://example.com/video.mp4"
|
||||
}),
|
||||
tags: [["d", "d1"]]
|
||||
});
|
||||
}
|
||||
},
|
||||
unsub: mock.fn(),
|
||||
};
|
||||
|
||||
client.pool.sub.mock.mockImplementation(() => subMock);
|
||||
|
||||
let capturedVideo = null;
|
||||
const sub = client.subscribeVideos((video) => {
|
||||
capturedVideo = video;
|
||||
});
|
||||
assert.ok(sub);
|
||||
|
||||
// Wait for buffer flush (75ms debounce)
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
|
||||
assert.ok(capturedVideo, "Video should be captured after flush");
|
||||
assert.equal(capturedVideo.id, "v1");
|
||||
assert.equal(capturedVideo.title, "Test Video");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishVideo", () => {
|
||||
it("should throw if not logged in", async () => {
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await client.publishVideo({}, null);
|
||||
},
|
||||
{ message: "Not logged in to publish video." }
|
||||
);
|
||||
});
|
||||
|
||||
it("should sign and publish a valid video", async () => {
|
||||
const pubkey = "testpubkey";
|
||||
client.pubkey = pubkey;
|
||||
|
||||
// Mock signing
|
||||
const signedEvent = { id: "evt1", pubkey, sig: "sig" };
|
||||
client.signAndPublishEvent = mock.fn(async () => ({ signedEvent }));
|
||||
client.mirrorVideoEvent = mock.fn(async () => ({ ok: true }));
|
||||
client.publishNip71Video = mock.fn(async () => null);
|
||||
|
||||
const videoPayload = {
|
||||
title: "My Video",
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
mimeType: "video/mp4"
|
||||
};
|
||||
|
||||
const result = await client.publishVideo(videoPayload, pubkey);
|
||||
|
||||
assert.equal(result, signedEvent);
|
||||
assert.equal(client.signAndPublishEvent.mock.callCount(), 1);
|
||||
|
||||
// Verify content structure passed to signer
|
||||
const signCall = client.signAndPublishEvent.mock.calls[0];
|
||||
const event = signCall.arguments[0];
|
||||
assert.equal(event.kind, 30078);
|
||||
const content = JSON.parse(event.content);
|
||||
assert.equal(content.title, "My Video");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editVideo", () => {
|
||||
it("should throw if not owner", async () => {
|
||||
const originalEvent = { id: "evt1", pubkey: "other" };
|
||||
client.getEventById = mock.fn(async () => ({ ...originalEvent, version: 2 }));
|
||||
|
||||
await assert.rejects(
|
||||
async () => await client.editVideo(originalEvent, {}, "me"),
|
||||
{ message: "You do not own this video (pubkey mismatch)." }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revertVideo", () => {
|
||||
it("should publish a deletion marker event", async () => {
|
||||
const pubkey = "my-pubkey";
|
||||
const originalEvent = {
|
||||
id: "evt1",
|
||||
pubkey,
|
||||
tags: [["d", "d1"]],
|
||||
content: JSON.stringify({ videoRootId: "root1", version: 2 })
|
||||
};
|
||||
|
||||
client.ensureActiveSignerForPubkey = mock.fn(async () => {});
|
||||
|
||||
// Mock signer registry resolution via client method or global mock if needed.
|
||||
// Since `revertVideo` calls `resolveActiveSigner`, we need to mock that external dependency
|
||||
// OR mock the internal logic. The client uses an import for `resolveActiveSigner`.
|
||||
// We cannot easily mock the imported function in ES modules without a loader.
|
||||
// However, `NostrClient` uses `resolveActiveSigner` from arguments passed to helper methods mostly,
|
||||
// BUT `revertVideo` imports it directly.
|
||||
|
||||
// Wait, `revertVideo` calls `resolveActiveSigner` which is imported.
|
||||
// This is hard to mock in unit tests without dependency injection or module mocking tools.
|
||||
// But `revertVideo` implementation:
|
||||
// const signer = resolveActiveSigner(pubkey);
|
||||
|
||||
// I might skip this test for now or try to use a workaround if I can't mock imports.
|
||||
// Alternatively, I can test `deleteAllVersions` or methods that I can control more easily.
|
||||
|
||||
// Actually, I can overwrite the property if it was attached to `this`, but it's not.
|
||||
// So I will skip deep integration tests that require mocking imports for now,
|
||||
// and focus on `fetch`/`subscribe` which use `this.pool`.
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import nostrService from "../../js/services/nostrService.js";
|
||||
|
||||
describe("NostrService", () => {
|
||||
let mockClient;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset state
|
||||
nostrService.videosMap = new Map();
|
||||
nostrService.dmMessages = [];
|
||||
|
||||
// Mock the internal nostrClient
|
||||
mockClient = {
|
||||
relays: ["wss://relay.example.com"],
|
||||
pool: {
|
||||
list: mock.fn(async () => []),
|
||||
},
|
||||
clampVideoRequestLimit: (l) => l || 100,
|
||||
getActiveVideos: mock.fn(() => []),
|
||||
getLatestCachedCreatedAt: mock.fn(() => 1000),
|
||||
subscribeVideos: mock.fn(() => ({ unsub: mock.fn() })),
|
||||
activeMap: new Map(),
|
||||
allEvents: new Map(),
|
||||
rawEvents: new Map(),
|
||||
applyRootCreatedAt: mock.fn(),
|
||||
populateNip71MetadataForVideos: mock.fn(async () => {}),
|
||||
applyTombstoneGuard: mock.fn(),
|
||||
recordTombstone: mock.fn(),
|
||||
};
|
||||
|
||||
nostrService.nostrClient = mockClient;
|
||||
|
||||
// Mock accessControl
|
||||
nostrService.accessControl = {
|
||||
waitForReady: mock.fn(async () => {}),
|
||||
canAccess: mock.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("loadVideos", () => {
|
||||
it("should load cached videos and start subscription", async () => {
|
||||
const mockVideos = [{ id: "v1", title: "Cached Video" }];
|
||||
mockClient.getActiveVideos.mock.mockImplementation(() => mockVideos);
|
||||
|
||||
const videos = await nostrService.loadVideos();
|
||||
|
||||
assert.equal(videos.length, 1);
|
||||
assert.equal(videos[0].id, "v1");
|
||||
assert.equal(mockClient.subscribeVideos.mock.callCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchVideosByAuthors", () => {
|
||||
it("should fetch videos from relays for specific authors", async () => {
|
||||
const authors = ["pubkey1"];
|
||||
const mockEvent = {
|
||||
id: "evt1",
|
||||
kind: 30078,
|
||||
pubkey: "pubkey1",
|
||||
created_at: 2000,
|
||||
content: JSON.stringify({
|
||||
title: "Author Video",
|
||||
videoRootId: "root1",
|
||||
url: "https://example.com/v.mp4"
|
||||
}),
|
||||
tags: [["d", "d1"], ["t", "video"]]
|
||||
};
|
||||
|
||||
mockClient.pool.list.mock.mockImplementation(async () => [mockEvent]);
|
||||
|
||||
const videos = await nostrService.fetchVideosByAuthors(authors);
|
||||
|
||||
assert.equal(videos.length, 1);
|
||||
assert.equal(videos[0].title, "Author Video");
|
||||
assert.equal(mockClient.pool.list.mock.callCount(), 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, test, mock, before, after, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createUiDom } from "../ui/helpers/jsdom-test-helpers.mjs";
|
||||
import { PlaybackService } from "../../js/services/playbackService.js";
|
||||
|
||||
describe("PlaybackService Forced Source Logic", () => {
|
||||
let dom;
|
||||
let document;
|
||||
let window;
|
||||
let service;
|
||||
let video;
|
||||
let playMock;
|
||||
let loadMock;
|
||||
let pauseMock;
|
||||
|
||||
before(() => {
|
||||
dom = createUiDom();
|
||||
window = dom.window;
|
||||
document = dom.document;
|
||||
|
||||
if (!window.HTMLMediaElement) {
|
||||
window.HTMLMediaElement = {
|
||||
HAVE_NOTHING: 0,
|
||||
HAVE_METADATA: 1,
|
||||
HAVE_CURRENT_DATA: 2,
|
||||
HAVE_FUTURE_DATA: 3,
|
||||
HAVE_ENOUGH_DATA: 4,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
dom.cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mock.timers.enable({ apis: ['setTimeout'] });
|
||||
service = new PlaybackService({
|
||||
logger: () => {},
|
||||
isValidMagnetUri: () => true,
|
||||
playbackStartTimeout: 100,
|
||||
});
|
||||
video = document.createElement("video");
|
||||
|
||||
playMock = mock.fn(async () => {});
|
||||
loadMock = mock.fn();
|
||||
pauseMock = mock.fn();
|
||||
|
||||
video.play = playMock;
|
||||
video.load = loadMock;
|
||||
video.pause = pauseMock;
|
||||
|
||||
Object.defineProperty(video, 'readyState', {
|
||||
writable: true,
|
||||
value: window.HTMLMediaElement.HAVE_NOTHING
|
||||
});
|
||||
Object.defineProperty(video, 'networkState', {
|
||||
writable: true,
|
||||
value: 0
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test("Normal flow: Timeout triggers fallback to Torrent if URL stalls", async () => {
|
||||
const probeUrl = mock.fn(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return { outcome: "good", status: 200 };
|
||||
});
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
});
|
||||
|
||||
const startPromise = session.start();
|
||||
|
||||
// Tick enough to trigger timeout (100ms) but not enough to resolve probeUrl (200ms)
|
||||
mock.timers.tick(150);
|
||||
// await microtasks to ensure timeout callback runs
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const result = await startPromise;
|
||||
|
||||
assert.equal(result.source, "torrent");
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test("Forced Source 'url': Ignores playbackStartTimeout and does NOT fallback to Torrent", async () => {
|
||||
const probeUrl = mock.fn(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return { outcome: "good", status: 200 };
|
||||
});
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
forcedSource: "url",
|
||||
});
|
||||
|
||||
const startPromise = session.start();
|
||||
|
||||
// Advance time past the 100ms default timeout
|
||||
mock.timers.tick(150);
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
// The promise should NOT be resolved yet (still probing)
|
||||
// We can't easily check 'pending' state without race, but we assume it didn't return 'torrent'.
|
||||
|
||||
// Advance time to complete probeUrl (200ms total)
|
||||
mock.timers.tick(100);
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
// Now trigger playing
|
||||
video.dispatchEvent(new window.Event("playing"));
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const result = await startPromise;
|
||||
|
||||
assert.equal(result.source, "url");
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("Forced Source 'url': Does NOT fallback to Torrent even if URL fails (probe bad)", async () => {
|
||||
const probeUrl = mock.fn(async () => {
|
||||
return { outcome: "bad", status: 404 };
|
||||
});
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
forcedSource: "url",
|
||||
});
|
||||
|
||||
const result = await session.start();
|
||||
|
||||
// It should have tried URL, failed, and returned error/null, skipping torrent fallback
|
||||
assert.equal(result.source, null); // Or maybe 'url' with error? No, execute returns { source: null, error } if everything fails.
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("Forced Source 'torrent': Ignores playbackStartTimeout and does NOT fallback to URL", async () => {
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "good" }));
|
||||
|
||||
// Mock torrent to take longer than timeout
|
||||
const playViaWebTorrent = mock.fn(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return { infoHash: "123" };
|
||||
});
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
forcedSource: "torrent",
|
||||
});
|
||||
|
||||
const startPromise = session.start();
|
||||
|
||||
// Advance time past default timeout
|
||||
mock.timers.tick(150);
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
// Complete torrent init
|
||||
mock.timers.tick(100);
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const result = await startPromise;
|
||||
|
||||
assert.equal(result.source, "torrent");
|
||||
assert.equal(probeUrl.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("Forced Source 'torrent': Does NOT fallback to URL even if Torrent fails", async () => {
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "good" }));
|
||||
|
||||
const playViaWebTorrent = mock.fn(async () => {
|
||||
throw new Error("Torrent failed");
|
||||
});
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
forcedSource: "torrent",
|
||||
});
|
||||
|
||||
const result = await session.start();
|
||||
|
||||
assert.equal(result.source, null);
|
||||
assert.equal(probeUrl.mock.callCount(), 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, test, mock, before, after, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PlaybackService } from "../../js/services/playbackService.js";
|
||||
|
||||
describe("PlaybackService Ordering", () => {
|
||||
let video;
|
||||
let window;
|
||||
|
||||
before(() => {
|
||||
// Mock global window/document
|
||||
global.window = {
|
||||
HTMLMediaElement: {
|
||||
HAVE_NOTHING: 0,
|
||||
HAVE_METADATA: 1,
|
||||
HAVE_CURRENT_DATA: 2,
|
||||
HAVE_FUTURE_DATA: 3,
|
||||
HAVE_ENOUGH_DATA: 4,
|
||||
},
|
||||
Event: class Event { constructor(type) { this.type = type; } },
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {}
|
||||
}
|
||||
};
|
||||
global.document = {
|
||||
createElement: (tag) => {
|
||||
if (tag === 'video') return video;
|
||||
return {};
|
||||
}
|
||||
};
|
||||
window = global.window;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete global.window;
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
describe("Playback Execution Order", () => {
|
||||
let playMock;
|
||||
let loadMock;
|
||||
let pauseMock;
|
||||
let eventListeners;
|
||||
|
||||
beforeEach(() => {
|
||||
mock.timers.enable({ apis: ['setTimeout'] });
|
||||
eventListeners = {};
|
||||
|
||||
video = {
|
||||
play: mock.fn(async () => {}),
|
||||
load: mock.fn(),
|
||||
pause: mock.fn(),
|
||||
removeAttribute: mock.fn(),
|
||||
addEventListener: (event, handler) => {
|
||||
if (!eventListeners[event]) eventListeners[event] = [];
|
||||
eventListeners[event].push(handler);
|
||||
},
|
||||
removeEventListener: (event, handler) => {
|
||||
if (eventListeners[event]) {
|
||||
eventListeners[event] = eventListeners[event].filter(h => h !== handler);
|
||||
}
|
||||
},
|
||||
dispatchEvent: (event) => {
|
||||
if (eventListeners[event.type]) {
|
||||
eventListeners[event.type].forEach(h => h(event));
|
||||
}
|
||||
},
|
||||
readyState: 0,
|
||||
networkState: 0,
|
||||
dataset: {},
|
||||
muted: false
|
||||
};
|
||||
|
||||
playMock = video.play;
|
||||
loadMock = video.load;
|
||||
pauseMock = video.pause;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test("urlFirstEnabled=true: Tries URL first, succeeds", async () => {
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => true,
|
||||
});
|
||||
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/vid.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
});
|
||||
|
||||
const startPromise = session.start();
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
video.dispatchEvent(new window.Event("playing"));
|
||||
const result = await startPromise;
|
||||
|
||||
assert.equal(probeUrl.mock.callCount(), 1, "Should probe URL");
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 0, "Should NOT call torrent");
|
||||
assert.equal(result.source, "url");
|
||||
});
|
||||
|
||||
test("urlFirstEnabled=true: Tries URL first, fails, falls back to Torrent", async () => {
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => true,
|
||||
});
|
||||
|
||||
// Probe returns error
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "bad", status: 404 }));
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/vid.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
});
|
||||
|
||||
const result = await session.start();
|
||||
|
||||
assert.equal(probeUrl.mock.callCount(), 1, "Should probe URL");
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 1, "Should fall back to torrent");
|
||||
assert.equal(result.source, "torrent");
|
||||
});
|
||||
|
||||
test("urlFirstEnabled=false: Tries Torrent first, succeeds", async () => {
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: false,
|
||||
isValidMagnetUri: () => true,
|
||||
});
|
||||
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/vid.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
});
|
||||
|
||||
const result = await session.start();
|
||||
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 1, "Should call torrent first");
|
||||
assert.equal(probeUrl.mock.callCount(), 0, "Should NOT probe URL");
|
||||
assert.equal(result.source, "torrent");
|
||||
});
|
||||
|
||||
test("urlFirstEnabled=false: Tries Torrent first, fails (throws), falls back to URL", async () => {
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: false,
|
||||
isValidMagnetUri: () => true,
|
||||
torrentClient: { cleanup: mock.fn() } // Mock cleanup
|
||||
});
|
||||
|
||||
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
||||
const playViaWebTorrent = mock.fn(async () => {
|
||||
throw new Error("Torrent failed");
|
||||
});
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/vid.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
});
|
||||
|
||||
const startPromise = session.start();
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
// Simulate successful playback for URL
|
||||
video.dispatchEvent(new window.Event("playing"));
|
||||
|
||||
const result = await startPromise;
|
||||
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 1, "Should call torrent first");
|
||||
assert.equal(probeUrl.mock.callCount(), 1, "Should fallback to probe URL");
|
||||
assert.equal(result.source, "url");
|
||||
});
|
||||
|
||||
test("forcedSource=torrent overrides urlFirstEnabled=true", async () => {
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => true,
|
||||
});
|
||||
|
||||
const probeUrl = mock.fn();
|
||||
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
||||
|
||||
const session = service.createSession({
|
||||
url: "https://example.com/vid.mp4",
|
||||
magnet: "magnet:?xt=urn:btih:123",
|
||||
videoElement: video,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
forcedSource: "torrent"
|
||||
});
|
||||
|
||||
const result = await session.start();
|
||||
|
||||
assert.equal(playViaWebTorrent.mock.callCount(), 1);
|
||||
assert.equal(probeUrl.mock.callCount(), 0);
|
||||
assert.equal(result.source, "torrent");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -134,7 +134,8 @@ describe("StorageService", () => {
|
||||
region: "us-east-1",
|
||||
bucket: "my-bucket",
|
||||
accessKeyId: "minio-key",
|
||||
secretAccessKey: "minio-secret"
|
||||
secretAccessKey: "minio-secret",
|
||||
forcePathStyle: true
|
||||
};
|
||||
const meta = {
|
||||
label: "Local Minio",
|
||||
@@ -151,6 +152,7 @@ describe("StorageService", () => {
|
||||
assert.strictEqual(retrieved.region, "us-east-1", "Region should be preserved");
|
||||
assert.strictEqual(retrieved.bucket, "my-bucket", "Bucket should be preserved");
|
||||
assert.strictEqual(retrieved.accessKeyId, "minio-key");
|
||||
assert.strictEqual(retrieved.forcePathStyle, true, "ForcePathStyle should be preserved");
|
||||
assert.strictEqual(retrieved.meta.defaultForUploads, true);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user