Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f6bdbfbcb | |||
| 8849ba6bef | |||
| 1cafd8d6e3 | |||
| f09bb4a11c | |||
| 973ebdd33a | |||
| bc7491a377 | |||
| c8df148546 | |||
| c1c06229c8 | |||
| bc53e0f730 | |||
| bd8f6ab855 | |||
| 611716d8b7 | |||
| a68bfb179b | |||
| 6bf1ad6b31 | |||
| d7f8f2df1d | |||
| 4927677e66 | |||
| 484aaa1849 | |||
| c99e17bd80 | |||
| e312f11bb1 | |||
| a465fa8471 | |||
| aeb686163b | |||
| 2d158a4540 | |||
| fc425bffa6 | |||
| 42a1136e2e | |||
| 53341264a7 | |||
| 9c16408d04 | |||
| 9b2537761f | |||
| 3b00e2721a | |||
| fc09e8a6b7 | |||
| 5483355021 | |||
| e2969bcee8 | |||
| 26802d2b23 | |||
| fbba447ca6 | |||
| d7a4a0b663 | |||
| 0443177471 | |||
| 0a83c716eb | |||
| b9e0d9f3cb | |||
| 2dee3b722c | |||
| 8daa7a1991 | |||
| f89ca2aceb | |||
| 57a6fb5daa | |||
| 49dafedbbc | |||
| b83523fc92 | |||
| 8ae5c332c3 | |||
| 420f9eb9a0 | |||
| 4314b11237 | |||
| eaea520020 | |||
| b8d22904f1 | |||
| cbd16ce3f6 | |||
| 017cfd7e28 | |||
| 954173cacf | |||
| 97934d641a | |||
| afbe6367cc | |||
| af38583b53 | |||
| eeb72fd677 | |||
| 320cb48b5d | |||
| f2a61ad88c | |||
| 8d84f9fe8c | |||
| e38e3c563c |
83
.agents/skills/security-audit/AI-AND-LLM.md
Normal file
83
.agents/skills/security-audit/AI-AND-LLM.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# AI, LLM, and Agent Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when a language model participates in a trust-sensitive decision: chatbots and assistants, RAG pipelines, persistent agent memory, agent/tool-calling loops, MCP servers and clients, code that builds prompts from untrusted input, or code that consumes model output and acts on it. The important data flow is *untrusted content → model or memory → capability, authority, or sink*.
|
||||||
|
|
||||||
|
Use this alongside `ATTACK-CLASSES.md`, not instead of it. Transport, access control, query construction, filesystem use, and output rendering remain ordinary trust boundaries. This file covers the model-specific delegation layer. Split large targets by retrieval, memory, tool dispatch, MCP, and output handling.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- Prompt injection alone is not a finding. Require a code-level boundary failure: content reaches another principal's context, invokes authority the requester lacks, discloses data they cannot read, or drives a sink they cannot reach directly.
|
||||||
|
- Model output, memory, tool descriptions, and MCP responses are untrusted inputs. Point to the code that grants authority, trusts output, writes durable state, or feeds a sink.
|
||||||
|
- A guardrail prompt is not a security boundary. Count only deterministic checks, resource-scoped authorization, isolation, binding, and constrained credentials.
|
||||||
|
- State the attacker, affected principal, effective execution identity, resource, exact action, authority used, and observable impact. An intentional direct request to use the requester's existing authority is not a delegation defect merely because a model executes it.
|
||||||
|
- Authorization and action binding are separate controls. Attacker-controlled content that causes an action under an affected principal's valid authority is an action-binding failure when that principal did not intentionally request or approve the exact action.
|
||||||
|
- Classify every candidate as `confirmed` only after source evidence and bounded local validation establish the boundary and result. Use `needs_validation` when a required provider, deployment, model, renderer, or identity behavior is not observable locally.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context, retrieval, and memory attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Indirect injection through retrieved or ingested content**
|
||||||
|
An attacker can write a RAG document, indexed page, file, email, issue body, tool response, or metadata that enters a different principal's model context. Trace who can write each source, how retrieval scopes it, whose session consumes it, and what capability is enabled there. Check isolation, resource authorization, and binding to the consuming principal's intent separately. The defect is a missing deterministic control, not persuasive text by itself.
|
||||||
|
|
||||||
|
**Cross-session or cross-tenant context bleed**
|
||||||
|
Conversation history, embeddings, retrieval results, or prompt caches are keyed too broadly. Verify tenant and ACL filters in the query itself and every cache key. A tenant field stored on an object is not enforcement if an alternate query, shared cache, or batch path omits it.
|
||||||
|
|
||||||
|
**Persistent memory poisoning**
|
||||||
|
Attacker-controlled content or model summaries are written into memory that later shapes another task, user, or privileged session. Review who may create, update, merge, and delete memory; its provenance and tenant scope; whether low-trust observations become durable instructions or facts; and whether retrieval distinguishes user preferences from tool policy. Memory intentionally saved by a user and used only for that user's intentional, allowed requests is not a cross-boundary finding.
|
||||||
|
|
||||||
|
**Prompt role and provenance confusion**
|
||||||
|
Prompt assembly lets untrusted text impersonate a system message, prior turn, tool result, policy, or memory record. Look for string concatenation, untyped history, caller-controlled role fields, and serialization round trips that lose source labels. Confirm that the forged provenance changes a deterministic trust decision or reaches a meaningful capability.
|
||||||
|
|
||||||
|
## Tool and action attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Tool-argument injection into a downstream sink**
|
||||||
|
Model-produced arguments reach SQL, shell, file, URL-fetch, or privileged APIs without handler-side validation. Treat the tool schema as input parsing, then follow each field from decoded call to sink. Structured output narrows shape; it does not establish authorization, safe paths, safe URLs, or query semantics.
|
||||||
|
|
||||||
|
**Excessive agency and confused-deputy authority**
|
||||||
|
The agent uses a service identity or broad credential, while the tool handler does not re-check the requesting principal's permission on the named resource. Verify both the effective identity and whether the caller could perform that exact operation through the normal product interface. A shared credential with enforced per-user query scope is not a defect.
|
||||||
|
|
||||||
|
**Action-confirmation and approval binding**
|
||||||
|
A user approves one described action but execution can use changed arguments, a different resource, a different principal, or a later model turn. An action-binding defect also exists when attacker-controlled content causes a side effect under a victim's valid authority without the victim's intentional request or approval, even if generic authorization permits the victim to perform it. Review whether intent or confirmation binds the normalized tool name, complete argument object, requester, target, amount, expiry, and batch membership. Check retries and resumed sessions: an approval must not authorize a mutated or duplicate side effect.
|
||||||
|
|
||||||
|
**Tool-schema and dispatcher disagreement**
|
||||||
|
The schema accepts aliases, extra fields, duplicate keys, coercions, nested free-form objects, or out-of-range values that the dispatcher or handler interprets differently. Compare schema validation, canonicalization, generated bindings, and handler defaults. Validate again where values become resource selectors or security-relevant options.
|
||||||
|
|
||||||
|
**Unbounded delegated action loops**
|
||||||
|
A bounded request can enqueue repeated spend, send, mutation, or external API work without a per-request budget, per-action authorization, cancellation, or idempotency control. Confirm impact on shared cost, quotas, other users, or durable state. Do not test by exhausting a service; use code-level accounting and a locally bounded loop.
|
||||||
|
|
||||||
|
## MCP and sub-agent trust classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Sub-agent and MCP trust inheritance**
|
||||||
|
A delegated task receives the full session, credentials, memory, or capabilities rather than the least authority required. Check the principal and tenant carried into each call, capability narrowing, credential audience, and whether delegated results are treated as untrusted on return.
|
||||||
|
|
||||||
|
**MCP server and tool identity confusion**
|
||||||
|
Calls or results are routed by attacker-influenceable server names, tool names, request IDs, resource URIs, or model-selected aliases rather than the authenticated connection and outstanding request. Check whether two servers can claim the same tool or resource identity, whether reconnect changes the binding, and whether a response from one server can satisfy another server's pending call.
|
||||||
|
|
||||||
|
**MCP metadata and schema as policy**
|
||||||
|
Tool descriptions, resource metadata, prompts, completion hints, or schemas supplied by an MCP peer are trusted as policy or authorization. These fields can guide the model but cannot grant capability. Find the deterministic allowlist, server identity check, and handler authorization that remain authoritative when metadata conflicts.
|
||||||
|
|
||||||
|
## Output and disclosure attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Insecure output rendering**
|
||||||
|
Model output reaches an executing HTML, Markdown, template, URL, or command sink without the sink's required encoding and policy. For browser rendering, verify auto-loaded resources and CSP or sanitization in `CLIENT-SIDE.md`; renderer behavior outside the repository makes the candidate `needs_validation`.
|
||||||
|
|
||||||
|
**Sensitive context extraction**
|
||||||
|
The assembled context contains credentials, another user's data, private source, or policy values that themselves grant access, and user-influenced output exposes them. Read prompt assembly and data-fetch code. Disclosure of generic instructions or behavior that does not cross a data boundary is not a finding.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Draw four maps first: each execution identity, each capability, every writable context or memory source, and each output destination. Then connect the principal at the start to the authority at the end.
|
||||||
|
- Start at side-effecting tools and work backward through dispatcher, schema, confirmation, model context, retrieval, and ingestion. Start at durable memory reads and trace every writer.
|
||||||
|
- Compare direct, queued, retry, resume, batch, and delegated paths for the same action. The strongest gate must apply after arguments are final and before every side effect.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Name the crossed boundary and observable result: attacker, affected principal or shared resource, execution identity, target, and unauthorized or unrequested action or disclosure.
|
||||||
|
2. For confused-deputy authority claims, prove the tool lacks requester-and-resource authorization and that the attacker cannot perform the same action normally. For action-binding claims, instead prove attacker-controlled content caused an action under the affected principal's authority that the principal did not intentionally request or approve. Valid generic authorization does not establish that intent.
|
||||||
|
3. For memory or retrieval claims, cite both the attacker-controlled write and the later cross-principal read or privileged decision. A shared record without a reachable consumer is not enough.
|
||||||
|
4. For action binding, establish the intentional request or normalized approved object, if any, and compare it with the object the handler uses. Confirm a locally observable unrequested action, mutation, duplicate, or authority change without extending the test into harmful execution. For schema disagreement, compare the normalized validated object with the handler's object.
|
||||||
|
5. For MCP identity claims, verify the authenticated connection, request correlation, tool namespace, and effective credential. Mark `needs_validation` if external server identity or deployment routing is required.
|
||||||
|
6. Return `confirmed` findings only with a complete source trace and meaningful result. Return `needs_validation` for a specific unresolved boundary fact and state the bounded local or owner-observed check needed to resolve it.
|
||||||
130
.agents/skills/security-audit/ATTACK-CLASSES.md
Normal file
130
.agents/skills/security-audit/ATTACK-CLASSES.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Attack Classes
|
||||||
|
|
||||||
|
#### Attack classes — choose and split based on Phase 1
|
||||||
|
|
||||||
|
Select attack classes relevant to the application type. Not every class applies to every codebase. The list below is a starting point; add application-specific classes from Phase 1 and split large codebases per subsystem. Frame work as finding, validating, fixing, and prioritizing vulnerabilities. Keep validation to source review and bounded local fixtures; do not develop payload chains, test availability on live services, or take action in shared environments.
|
||||||
|
|
||||||
|
Use `confirmed` only when source evidence and bounded validation establish the full boundary and meaningful result. Use `needs_validation` when a specific deployment, provider, platform, identity, or runtime fact is unavailable; state the missing fact and the safe owner-observed or local check that resolves it.
|
||||||
|
|
||||||
|
> **Native / binary / kernel targets** (C/C++/Rust-unsafe, kernel modules, parsers and decoders, FFI, concurrent runtimes, binary loaders, JITs, firmware): use the memory-safety, integer/ABI, concurrency, binary-loader, and privileged-interface classes in [MEMORY-SAFETY-AND-BINARY.md](MEMORY-SAFETY-AND-BINARY.md).
|
||||||
|
>
|
||||||
|
> **AI / LLM / agent targets** (chatbots, RAG, persistent memory, tool-calling agents, MCP servers/clients, prompt assembly, or model-controlled actions): use the context, memory-poisoning, action-binding, tool-schema, MCP-identity, and output classes in [AI-AND-LLM.md](AI-AND-LLM.md).
|
||||||
|
>
|
||||||
|
> **HTTP, web, and identity targets** (ordinary web apps, APIs, reverse proxies, CDNs, gateways, custom HTTP parsers, sessions, CSRF, JWT, OAuth/OIDC, SAML, MFA, passkeys, account recovery/linking, API keys, or mTLS): use [WEB-PROTOCOL-AND-AUTH.md](WEB-PROTOCOL-AND-AUTH.md).
|
||||||
|
>
|
||||||
|
> **Client-side and browser targets** (SPAs, browser extensions, embedded webviews, service workers, browser storage, cross-window messaging, CORS, WebSockets, or DOM rendering): use [CLIENT-SIDE.md](CLIENT-SIDE.md).
|
||||||
|
>
|
||||||
|
> **Supply-chain and release targets** (dependency resolution, generated inputs, CI, release/signing/promotion, updates, plugins, or extensions): use [SUPPLY-CHAIN-AND-RELEASE.md](SUPPLY-CHAIN-AND-RELEASE.md).
|
||||||
|
>
|
||||||
|
> **Cloud and deployment targets** (IAM, infrastructure as code, containers/Kubernetes, service mesh, serverless/edge, ingress, provider events, or runtime configuration): use [CLOUD-AND-DEPLOYMENT.md](CLOUD-AND-DEPLOYMENT.md).
|
||||||
|
>
|
||||||
|
> **Protocol, RPC, and messaging targets** (gRPC, GraphQL transports, Protobuf/Cap'n Proto/Thrift, custom protocols, queues, brokers, pub/sub, webhooks, or streaming RPC): use [PROTOCOLS-RPC-AND-MESSAGING.md](PROTOCOLS-RPC-AND-MESSAGING.md).
|
||||||
|
>
|
||||||
|
> **Resource-exhaustion and availability targets** (untrusted work can consume shared CPU, memory, disk, connections, workers, queues, quotas, or operator-owned spend): use [RESOURCE-EXHAUSTION-AND-AVAILABILITY.md](RESOURCE-EXHAUSTION-AND-AVAILABILITY.md).
|
||||||
|
>
|
||||||
|
> **Data-isolation and lifecycle targets** (multi-tenant stores, caches/search, object links, analytics, export/backup, migration, deletion, retention, or restore): use [DATA-ISOLATION-AND-LIFECYCLE.md](DATA-ISOLATION-AND-LIFECYCLE.md).
|
||||||
|
>
|
||||||
|
> **Desktop, mobile, and local-IPC targets** (native apps, deep links, webview bridges, exported components, privileged helpers, local daemons, Unix sockets/XPC/Binder/D-Bus): use [DESKTOP-MOBILE-AND-LOCAL-IPC.md](DESKTOP-MOBILE-AND-LOCAL-IPC.md).
|
||||||
|
|
||||||
|
**Injection** (subagent_type: `general`)
|
||||||
|
Trace untrusted input from entry point to dangerous sink. What counts as a "dangerous sink" depends on the application:
|
||||||
|
- Web apps: SQL queries, HTML output, shell commands, template engines, file paths, HTTP redirects, deserialization
|
||||||
|
- Libraries: any function that processes caller-supplied data without validation — buffer operations, parsers, format strings
|
||||||
|
- CLI tools: shell command construction, file path handling, environment variable interpolation
|
||||||
|
- Services: query construction, message serialization, log injection, LDAP/XPATH queries
|
||||||
|
- Client-side (browser/JS): DOM XSS, prototype pollution, `postMessage`/origin trust, and other browser-side classes — covered by the [CLIENT-SIDE.md](CLIENT-SIDE.md) companion blocks when selected
|
||||||
|
|
||||||
|
Do not stop at the obvious direct paths. Look for indirect injection: data stored safely, then retrieved and used in a dangerous context by different code. Look for injection through field names, keys, headers, and metadata — not just values. Look for injection into secondary systems (logs, caches, search indexes, analytics).
|
||||||
|
|
||||||
|
**Access control** (subagent_type: `general`)
|
||||||
|
Verify that a caller cannot do something outside its authority. Go beyond checking whether permission checks exist — verify they check the *right* permission for the *right* resource via the *right* mechanism:
|
||||||
|
- Is there a path to the same state change that checks a different (weaker) permission?
|
||||||
|
- Can a field in the request body override what the permission system intended to restrict?
|
||||||
|
- Are there endpoints that gate on authentication but forget authorization?
|
||||||
|
- Does the same resource have multiple access paths with inconsistent checks?
|
||||||
|
- What about bulk/batch/export/import operations — do they enforce per-item permissions?
|
||||||
|
|
||||||
|
For complex access models, split into separate agents for auth bypass vs authorization logic.
|
||||||
|
|
||||||
|
**Resource and file handling** (subagent_type: `general`)
|
||||||
|
- Path traversal (reading/writing outside intended directories) — including through symlinks, encoded sequences, and null bytes
|
||||||
|
- SSRF (making the application fetch attacker-controlled URLs) — including through redirects, DNS rebinding, and URL parser differentials
|
||||||
|
- Unsafe deserialization, archive extraction (zip slip), temp file handling
|
||||||
|
- Memory safety (if applicable): buffer overflows, use-after-free, integer overflow
|
||||||
|
- Race conditions on file operations (TOCTOU between check and use)
|
||||||
|
|
||||||
|
**Cryptography and secrets** (subagent_type: `general`)
|
||||||
|
- Weak randomness for security-critical values (tokens, keys, nonces)
|
||||||
|
- Hardcoded secrets, secrets in logs, error messages, URLs, or client-visible responses
|
||||||
|
- Broken key derivation, missing HMAC verification, nonce reuse
|
||||||
|
- Timing side-channels on secret comparison
|
||||||
|
- Misuse of crypto primitives (ECB mode, unauthenticated encryption, static IVs, etc.)
|
||||||
|
- What happens when crypto operations fail? Does the error path fall back to no-crypto?
|
||||||
|
|
||||||
|
**Business logic** (subagent_type: `general`)
|
||||||
|
Hunt logic errors by hand: standard scanners cannot find them, and they yield high-impact findings. For each major workflow:
|
||||||
|
- **State machine violations**: Can you skip steps? Go backwards? Reach an invalid state? What happens if you replay a completed flow? What about partial failure — if step 2 of 3 fails, is step 1 rolled back?
|
||||||
|
- **Race conditions with business impact**: Concurrent operations that produce invalid states (double-spend, double-approve, lost updates). Focus on operations that check-then-act non-atomically.
|
||||||
|
- **Numeric/quantity manipulation**: Negative values, zero values, overflow, precision loss, type coercion between string and number.
|
||||||
|
- **Access boundary violations**: Not "does the permission check exist" but "is it the right check for the business rule?" Can input to one operation bypass a restriction enforced on a different operation for the same effect?
|
||||||
|
- **Implicit trust assumptions**: Data from storage, config, other components, or plugins assumed safe because "we validated it on the way in." What if a different code path wrote it?
|
||||||
|
- **Time-based logic**: Expiry checks, scheduling, rate windows, clock skew. What happens at exact boundary moments? What about timezone differences between components?
|
||||||
|
- **Default and fallback behavior**: What is the security posture when config is missing? When a feature flag is off? When a dependency is unavailable? When the system is mid-migration?
|
||||||
|
|
||||||
|
**Feature abuse and data leakage** (subagent_type: `general`)
|
||||||
|
Legitimate features used for unintended purposes. Look for bugs in the design, not only in the code:
|
||||||
|
- **Export/backup as exfiltration**: Can a low-privilege user trigger an export, snapshot, or backup that includes data above their access level? Can they export other users' data? Does the export include deleted/draft/private content? Revision history that was supposed to be pruned?
|
||||||
|
- **Import/restore as injection**: Can import overwrite existing data? Can it create records that bypass normal validation? Can it inject content into collections the user has no write access to? Does it respect the same permission model as the UI?
|
||||||
|
- **Search/filter/sort as oracle**: Can search queries reveal whether content exists that the user cannot directly access? Do filter parameters let users probe statuses, roles, or fields they should not know about? Does sorting by a hidden field reveal its values through result ordering?
|
||||||
|
- **Enumeration through side effects**: Do error messages differ between "does not exist" and "no access"? Do response times differ? Response sizes? HTTP status codes? Can you enumerate users through password reset, invite, or registration flows?
|
||||||
|
- **Preview/draft/staging leakage**: Are preview tokens scoped to one item or do they unlock broader access? Can draft content be discovered through search, RSS feeds, sitemaps, or API listing endpoints? Can cache headers cause a CDN to serve private content publicly?
|
||||||
|
- **Notification/webhook as SSRF**: Can a user set a notification URL, webhook URL, or callback URL that the server fetches? Is it validated against internal networks? What about after a redirect?
|
||||||
|
|
||||||
|
**Chained vulnerabilities and trust boundaries** (subagent_type: `general`)
|
||||||
|
Individually allowed or contained behavior can become a vulnerability when another component or lifecycle step relies on a stronger guarantee:
|
||||||
|
- **Multi-step boundary failures**: Map what a low-privilege principal may read, write, invoke, and retain, then connect only concrete outputs to later trust decisions. Confirm each prerequisite and do not assume a downstream effect.
|
||||||
|
- **Cross-component trust gaps**: Component A validates input and passes it to component B. Compare the exact guarantee A produces with what B assumes, including truncation, type coercion, normalization, tenant scope, and plugin/extension access.
|
||||||
|
- **Second-order use**: Data safe when stored may become dangerous in a later context. A field name becomes a JSON path, a slug becomes a file path, escaped text enters raw rendering, or a stored string becomes a URL, regex, template, or policy expression.
|
||||||
|
- **Scope and capability growth**: Token, API-key, plugin, OAuth, MCP, or AI capabilities become broader after delegation, refresh, caching, role change, or composition. Name the concrete operation the resulting principal should not have.
|
||||||
|
- **Timing and ordering**: Review setup, migration, soft-delete, revoke/cache expiry, check/use, and validate/consume windows. Confirm stale state is accepted before reporting.
|
||||||
|
- **Rollback and recovery**: Undelete, restore, revision rollback, and cancellation must apply current ownership, validation, and authorization. Confirm which invalid state is restored.
|
||||||
|
|
||||||
|
**Wildcard** (subagent_type: `general`)
|
||||||
|
You are not given a category. Find vulnerabilities outside the standard classes already assigned.
|
||||||
|
|
||||||
|
Read code that looks boring or disconnected from security. Follow incomplete, experimental, compatibility, and fallback features, but retain the same concrete boundary and validation requirements as every other class.
|
||||||
|
|
||||||
|
Use these starting points, but do not limit yourself to them:
|
||||||
|
- What is the strangest code in the codebase? Why does it exist? What happens if it is abused?
|
||||||
|
- Are there any features that feel half-finished, experimental, or bolted on? Those have the weakest security because they got the least review.
|
||||||
|
- What happens if you use the API in a way the frontend never would? The UI constrains users, but the API does not. What API calls are possible but never made by the client?
|
||||||
|
- Are there any hidden or undocumented endpoints, parameters, headers, or features? Look at route registrations, middleware, and config for things that are not in the docs.
|
||||||
|
- What happens when you mix features that were not designed to work together? Localization + preview + caching. Import + plugins + webhooks. OAuth + impersonation + API keys.
|
||||||
|
- Is there anything interesting in the git history? Reverted security fixes, commented-out auth checks, secrets that were committed then removed (still in history).
|
||||||
|
- Which valid-account actions affect other users, shared integrity, availability, or operator-owned cost? Verify containment, quotas, authorization, and recovery around those actions.
|
||||||
|
- Which operations are irreversible or require elevated confirmation? Bind authorization and approval to the final principal, action, and resource.
|
||||||
|
- What assumptions does the code make about the environment? That the database is local, that the clock is accurate, that DNS is trustworthy, that the filesystem is case-sensitive?
|
||||||
|
- Look at the test files — what are they **not** testing? Compare the edge cases the developer thought about (tests exist) with the ones they did not (no tests).
|
||||||
|
|
||||||
|
Pursue anomalies inside your assigned scope until the invariant is settled. If something looks strange, read it until you can state whether it is safe. If a function has a comment explaining why it is safe, verify the explanation. If a variable is named `temp` or `hack` or `legacy`, read it closely.
|
||||||
|
|
||||||
|
**Obvious things** (subagent_type: `general`)
|
||||||
|
Other agents hunt subtle bugs. This agent checks the basic exposures that are easy to overlook because everyone assumes someone else already checked them:
|
||||||
|
- Are there any hardcoded passwords, API keys, tokens, or secrets in the source? (grep for `password`, `secret`, `apikey`, `token`, `Bearer`, `-----BEGIN`, common default passwords)
|
||||||
|
- Are there any TODO/FIXME/HACK/XXX comments that reference security? (`TODO: add auth`, `FIXME: validate input`, `HACK: skip permission check`)
|
||||||
|
- Is debug mode / dev mode properly gated? Can it be enabled in production via environment variable, query parameter, or header?
|
||||||
|
- Are there test/example/seed credentials that work in production?
|
||||||
|
- Is there a `/debug`, `/admin`, `/test`, `/status`, `/health`, `/metrics`, `/env`, `/.env`, `/config` endpoint that is unprotected?
|
||||||
|
- Are there any `.env`, `.env.local`, `credentials.json`, `*.pem`, `*.key` files checked into the repo?
|
||||||
|
- Does the `.gitignore` actually cover secrets, uploads, and local config?
|
||||||
|
- Are dependencies pinned? Are there known CVEs in the dependency tree? (check lockfiles)
|
||||||
|
- Are there any `eval()`, `exec()`, `child_process`, `Function()`, `vm.runInContext`, `import()` with dynamic input?
|
||||||
|
- Are CORS headers set to `*` or overly permissive? Is `Access-Control-Allow-Credentials` combined with a wildcard origin?
|
||||||
|
- Are cookies missing `HttpOnly`, `Secure`, or `SameSite` attributes?
|
||||||
|
- Are there any open redirects? (parameters named `redirect`, `return`, `next`, `url`, `goto`, `continue` that feed into redirects without validation)
|
||||||
|
- Is TLS enforced? Are there any HTTP-only endpoints?
|
||||||
|
- Are error responses in production returning stack traces, internal paths, or SQL errors?
|
||||||
|
|
||||||
|
This agent does not need to be creative. It needs to be thorough and literal. Check every item. Report each result.
|
||||||
|
|
||||||
|
**Important**: For any finding this agent reports, it must verify the full code path, not just surface appearance. If a cookie is missing `HttpOnly`, check whether the cookie contains security-sensitive data and whether JS needs to read it by design. If an error message contains a field name, check whether the field is ever actually populated with sensitive data. A flag is not a finding — trace the impact before reporting.
|
||||||
83
.agents/skills/security-audit/CLIENT-SIDE.md
Normal file
83
.agents/skills/security-audit/CLIENT-SIDE.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Client-Side and Browser Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when meaningful trust decisions or untrusted rendering happen in a browser: single-page apps, browser extensions, embedded webviews, service workers, offline applications, and code that renders attacker-influenceable content into the DOM, receives cross-window messages, or uses browser storage. These paths include sources the server never sees, such as URL fragments, `window.name`, `postMessage`, and previously cached content.
|
||||||
|
|
||||||
|
Use alongside `ATTACK-CLASSES.md`. This file covers browser sources and sinks, origin boundaries, browser persistence, and cross-site state oracles. Use `DESKTOP-MOBILE-AND-LOCAL-IPC.md` for the native side of a webview bridge, and `WEB-PROTOCOL-AND-AUTH.md` for server-side CSRF, sessions, and auth callbacks.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- A client-side candidate needs a controllable source and an executing or disclosing sink. Name both and show attacker-influenced data reaching the sink.
|
||||||
|
- The impact must reach a victim's session, another origin, or shared persistence. Self-injection and disclosure of the attacker's own data are not findings.
|
||||||
|
- Framework escaping, browser same-origin policy, CSP, COOP/CORP, service-worker scope, and modern noopener defaults are real controls. Verify them before assigning impact.
|
||||||
|
- Browser storage and caches are shared by origin and may outlive login state. Identify who writes, who reads, and which account, tenant, or worker lifecycle clears each record.
|
||||||
|
- Use `confirmed` only for complete source evidence plus bounded local browser tests. Use `needs_validation` when renderer, extension permission, deployed header, or browser-policy behavior is required but unavailable.
|
||||||
|
```
|
||||||
|
|
||||||
|
## DOM and object-state attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**DOM-based XSS**
|
||||||
|
Trace `location` fields, `document.referrer`, `window.name`, message data, storage, and browser-controlled document state into `innerHTML`, `outerHTML`, `document.write`, string-evaluating APIs, executable URLs, jQuery HTML APIs, or framework escape hatches. Interpolation escaped by the framework is not a finding.
|
||||||
|
|
||||||
|
**DOM clobbering**
|
||||||
|
Attacker-injected `id` or `name` attributes shadow a global, form property, configuration object, or initialization flag later trusted by code. Require both a markup path that preserves the attribute and a security-relevant use of the clobbered value.
|
||||||
|
|
||||||
|
**Prototype pollution and gadget chain**
|
||||||
|
An attacker-controlled key reaches a recursive write such as deep merge or path assignment and modifies prototype state. Then a reachable gadget consumes the polluted property to change authorization, execution, navigation, or rendering. `JSON.parse`, a shallow copy, or pollution without a gadget is not enough.
|
||||||
|
|
||||||
|
## Cross-origin messaging and network attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**`postMessage` origin and source trust**
|
||||||
|
A handler performs a sensitive action with `event.data` without an exact origin allowlist and, where multiple frames share an origin, the expected `event.source`. On the send side, sensitive data sent to `*` reaches an unintended embedder. Weak substring, prefix, suffix, or unanchored-regex origin matching is not an origin check.
|
||||||
|
|
||||||
|
**Cross-site WebSocket request use**
|
||||||
|
A WebSocket upgrade accepts ambient cookies from an untrusted origin without an `Origin` check or channel-specific token, allowing the victim's session to read or mutate data. Confirm both the upgrade behavior and a security-relevant message handler.
|
||||||
|
|
||||||
|
**Credentialed CORS trust**
|
||||||
|
The server reflects or weakly matches `Origin` while allowing credentials and returns sensitive responses. A bare wildcard with credentials is rejected by browsers; report only the actual reflected/allowed origin path and cross-origin data or mutation.
|
||||||
|
|
||||||
|
## Service-worker and browser-storage attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Service-worker registration and scope takeover**
|
||||||
|
Attacker-influenceable content can become the registered worker script, control a path that receives an over-broad `Service-Worker-Allowed` scope, or alter update imports without integrity control. Verify the final script URL, response MIME type, origin, scope, and who controls every imported script. A normal same-origin worker with intended scope is not a defect.
|
||||||
|
|
||||||
|
**Service-worker cache and identity confusion**
|
||||||
|
The worker caches personalized responses without including account, tenant, authorization state, or request mode in its policy, then serves them after account switch or logout. Review fetch-event routing, cache names and keys, navigation fallbacks, cache cleanup, and whether error/offline paths return another user's prior response.
|
||||||
|
|
||||||
|
**Browser-storage disclosure and stale authorization**
|
||||||
|
Tokens, private responses, draft data, or authorization decisions remain in `localStorage`, `sessionStorage`, IndexedDB, Cache Storage, extension storage, or client state and become readable by another account or less-trusted same-origin component. Storage of a token alone is not a finding; require a realistic reader with less authority, or continued use after revocation/logout.
|
||||||
|
|
||||||
|
**Cross-context storage and broadcast confusion**
|
||||||
|
`storage` events, `BroadcastChannel`, shared workers, or origin-wide caches carry identity or commands between tabs without binding them to the current session. Check account switching, private/public windows, tenant changes, and stale tabs that can overwrite newer auth state.
|
||||||
|
|
||||||
|
## Cross-site information leak classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**XS-Leaks and cross-origin state oracles**
|
||||||
|
An attacker page can distinguish protected cross-origin state through resource load/error events, frame or window state, redirect behavior, timing, cache state, or response size while the browser attaches victim credentials. Require one concrete secret-bearing predicate such as whether a private object, role, or account exists. Generic timing variance or public-resource availability is not a finding.
|
||||||
|
|
||||||
|
**Window and opener state disclosure**
|
||||||
|
A cross-origin window's permitted metadata or navigation result reveals protected state, or a retained opener/named-window relationship lets an attacker-controlled page influence a privileged navigation. Check COOP, frame protections, `noopener`, exact origin, and whether the observable state is confidential.
|
||||||
|
|
||||||
|
## UI-redress and navigation attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Clickjacking**
|
||||||
|
A framed, state-changing action lacks effective `frame-ancestors`, `X-Frame-Options`, or equivalent UI isolation. Require the sensitive action and confirm it can complete in the framed state; missing headers on read-only content are hardening notes.
|
||||||
|
|
||||||
|
**Client-side navigation confusion**
|
||||||
|
A client source controls redirect or navigation without scheme and destination policy, including executable `javascript:` or `data:` destinations. Reverse tabnabbing applies only where code explicitly keeps `window.opener`, uses `window.open` without isolation, or supports a browser without implicit `noopener`.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Start from DOM, navigation, worker, message, and storage sinks, then trace backward to browser-only and server-controlled sources. Record the browser policy that should stop the path.
|
||||||
|
- Test account switch, logout, worker update, offline fallback, and stale-tab state with a local test origin and dummy accounts. Do not use production users, origins, or shared services.
|
||||||
|
- For XS-Leaks, list only predicates proved by source and local browser behavior. Then identify the response headers or rendering choice that would remove the oracle.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Cite the source, sink, browser policy, affected origin/session, and observable mutation or disclosure.
|
||||||
|
2. For prototype pollution, prove the recursive write and a security-relevant gadget. For DOM clobbering, prove the markup survives and the shadowed value is used.
|
||||||
|
3. For service workers and storage, prove lifecycle reachability: an attacker-controlled write or cache entry must reach a different account, tenant, or later authorization state.
|
||||||
|
4. For messaging, CORS, WebSocket, and XS-Leaks, show exact origin/source validation and the protected state or action exposed. Confirm that CSP, COOP/CORP, cookies, and SameSite policy do not already block it.
|
||||||
|
5. Return `confirmed` findings only with a complete client path and bounded local evidence. Return `needs_validation` with the precise deployed header, extension permission, browser version, or renderer behavior an owner must verify.
|
||||||
86
.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md
Normal file
86
.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Cloud and Deployment Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when the repository defines cloud identity, infrastructure, containers, Kubernetes, service mesh, serverless functions, edge workers, ingress, object storage, managed services, or environment-specific configuration. This domain asks whether deployed components receive the intended identity, isolation, network reachability, secrets, and policy. Source often expresses intent rather than live fact, so separate source-confirmed defects from deployment validation needs.
|
||||||
|
|
||||||
|
Use `SUPPLY-CHAIN-AND-RELEASE.md` for build and promotion trust, `WEB-PROTOCOL-AND-AUTH.md` for HTTP proxy semantics, and `DATA-ISOLATION-AND-LIFECYCLE.md` for data-store tenant scope.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- Do not infer a live exposure from a manifest alone. Establish which environment consumes it, what defaults or overlays modify it, and whether the source path is active.
|
||||||
|
- Map each workload's identity to specific operations and resources. Broad policy is a finding only when lower-trust input can reach an unauthorized action.
|
||||||
|
- Ingress, proxies, service mesh, metadata services, and admission policy are real boundaries, but only count a control when its configuration and attachment are visible.
|
||||||
|
- Secret references are not secret disclosure. Require a lower-trust reader, output, artifact, log path, or unsafe fallback.
|
||||||
|
- Use `confirmed` for active in-repo configurations and local rendering/policy validation. Use `needs_validation` for account policy, network attachment, runtime admission, hosted metadata, or drift that needs owner observation.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workload identity and IAM attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Workload identity overreach**
|
||||||
|
A workload, pod, function, edge worker, or node identity can act on tenants, accounts, resources, or APIs beyond its role, and untrusted request or job input selects that target. Review cloud policy conditions, resource patterns, service-account attachment, namespace mapping, and fallback credentials.
|
||||||
|
|
||||||
|
**Cross-account or cross-tenant role confusion**
|
||||||
|
Role assumption, external IDs, token exchange, workload federation, or resource policies accept identity claims not bound to the intended source account, audience, repository, namespace, or workload. Establish both trust policy and caller-controlled claim.
|
||||||
|
|
||||||
|
**Application authorization delegated to cloud metadata**
|
||||||
|
An app trusts caller-supplied identity headers, tags, labels, account IDs, or resource metadata without verifying they came from the cloud control plane or a trusted proxy. Cloud IAM and application authorization are separate checks.
|
||||||
|
|
||||||
|
## Ingress, network, and control-plane attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Unexpected service or management-plane reachability**
|
||||||
|
An ingress, service, listener, security group, load-balancer annotation, port mapping, or server bind exposes an admin, debug, metrics, node, control-plane, or internal API to a lower-trust network. Missing network controls alone are `needs_validation`; a repository-controlled public route to a sensitive handler can be `confirmed`.
|
||||||
|
|
||||||
|
**Trusted-proxy and mesh identity bypass**
|
||||||
|
A backend accepts forwarded identity, mTLS subject, or authorization metadata from peers outside the intended ingress/sidecar, or an alternate port and health/legacy path bypasses the mesh. Verify header stripping, peer reachability, and fail-open behavior when the proxy is absent.
|
||||||
|
|
||||||
|
**Metadata and internal-service reachability**
|
||||||
|
An untrusted URL, destination, or protocol selection reaches instance/container metadata, control-plane sockets, or internal APIs with workload credentials. Trace URL parsing and redirect handling under `ATTACK-CLASSES.md`; here establish deployed network, metadata-version, and identity boundaries.
|
||||||
|
|
||||||
|
## Container and orchestration attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Host or control-plane capability exposure**
|
||||||
|
A lower-trust workload can select privileged mode, capabilities, host namespaces, host paths, device mounts, container runtime sockets, or service-account tokens that cross into node/control-plane authority. Bare absence of seccomp or read-only filesystem is hardening unless a reachable operation crosses that boundary.
|
||||||
|
|
||||||
|
**Admission and policy path inconsistency**
|
||||||
|
One deployment route enforces image identity, namespace, resource, secret, or privilege policy while another controller, job, upgrade, restore, or compatibility path does not. Confirm the alternate route and resulting deployed object.
|
||||||
|
|
||||||
|
**Namespace and label trust confusion**
|
||||||
|
Network, admission, secret, or workload-identity policy relies on labels, annotations, names, or namespaces that a less-trusted principal can set. Compare who controls selectors with what authority matching grants.
|
||||||
|
|
||||||
|
## Configuration and secret lifecycle attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Security-control precedence drift**
|
||||||
|
Development values, chart defaults, environment variables, command-line flags, feature gates, sidecar injection, or per-region overlays disable authentication, transport security, tenant scoping, or audit policy in a deployed environment. Render the final configuration for each maintained deployment, not just the base file.
|
||||||
|
|
||||||
|
**Secret exposure across workload boundaries**
|
||||||
|
Secrets enter logs, crash reports, process arguments, shared environment, broad volumes, build outputs, service discovery, or read APIs accessible to another workload or tenant. Check secret type and authority; a public endpoint or key ID is not a credential.
|
||||||
|
|
||||||
|
**Credential renewal and outage fallback**
|
||||||
|
Failure to mount, refresh, rotate, or revoke a workload credential causes stale credentials to remain active or an app to accept a less trusted identity mode. Review startup, readiness, reconnect, and cached-client behavior.
|
||||||
|
|
||||||
|
## Managed storage, events, and edge attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Object and signed-URL policy confusion**
|
||||||
|
Bucket/container policy, object keys, CDN origins, or signed URLs fail to bind principal, operation, object namespace, audience, or expiry. Review list/version operations and write paths as well as reads.
|
||||||
|
|
||||||
|
**Event-source identity confusion**
|
||||||
|
A function or worker trusts event body fields as source identity without validating provider-signed envelope, subscription/topic, account, region, and replay state. Compare push, pull, retry, and dead-letter paths.
|
||||||
|
|
||||||
|
**Edge/runtime boundary mismatch**
|
||||||
|
An edge or serverless runtime assumes a secret, API, filesystem, isolation, or tenant policy that differs from the origin runtime, and fallback to origin changes authority or cache behavior. Confirm which configuration selects each path.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Render every maintained environment and make a matrix of external port, workload identity, network peers, mounted secrets, and cloud resources. Differences require an owner or policy explanation.
|
||||||
|
- Follow a lower-trust request, object, label, or event into cloud policy. Show which workload credential performs the final operation and what condition should scope it.
|
||||||
|
- Diff normal deploy, migration, restore, node maintenance, failover, and local/emulator paths. Review behavior when mesh, admission, identity, secret, or policy service is unavailable.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Establish the active source path and effective deployment object; otherwise use `needs_validation` and state which rendered manifest or owner-observed attachment is missing.
|
||||||
|
2. Name the lower-trust caller/workload, cloud or application identity, controllable selector, affected resource, and unauthorized operation or disclosure.
|
||||||
|
3. Verify provider and orchestrator defaults at the pinned version. Do not assume a public IP, reachable metadata service, permissive firewall, or absent admission attachment.
|
||||||
|
4. Local validation may render templates, evaluate policy, inspect container/user namespaces in an isolated fixture, or run an emulator with dummy identities. Do not probe live endpoints or alter shared cloud resources.
|
||||||
|
5. Return `confirmed` only with a complete active source trace and concrete boundary result. Return `needs_validation` with the exact deployed policy, identity attachment, overlay, network, or drift observation needed.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Data Isolation and Lifecycle Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when the target stores multi-tenant or access-controlled data, derives search/index/cache/analytics copies, issues object links, exports or restores records, migrates schemas, or promises deletion, revocation, and retention behavior. This domain follows one data item through every copy and state transition. Use `ATTACK-CLASSES.md` for endpoint-level access control and `CLOUD-AND-DEPLOYMENT.md` for provider-level storage policy.
|
||||||
|
|
||||||
|
Split large targets by primary storage, cache/search, object/blob storage, analytics/logging, export/backup, deletion/revocation, and migration.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- A tenant or owner field on a record is not isolation. Find the query, key, path, policy, or row-level control that enforces it for each read and write path.
|
||||||
|
- Trace derived copies. Sanitized primary data can become unsafe in search, cache, analytics, export, previews, logs, replicas, and backups with different ACL and retention rules.
|
||||||
|
- Deletion and revocation are lifecycle contracts. Check current, historical, cached, indexed, exported, restored, and queued copies within the product's stated boundary.
|
||||||
|
- Privacy or retention preference is not automatically a security vulnerability. Require an explicit data-access boundary or deletion/revocation guarantee and an unauthorized reader or later operation.
|
||||||
|
- Use `confirmed` for complete source-visible lineage and bounded dummy-tenant tests. Use `needs_validation` when external storage policy, retention, CDN behavior, replica lag, or backup access is unavailable.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tenant and object-isolation attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Missing tenant or owner enforcement**
|
||||||
|
A read, update, delete, list, count, or bulk query identifies an object without binding it to the authenticated tenant/owner, or trusts body fields to supply that identity. Compare direct lookup, nested relationship, background, admin, import, and legacy paths.
|
||||||
|
|
||||||
|
**Composite-key and namespace collision**
|
||||||
|
Cache keys, object paths, database uniqueness, search document IDs, temporary files, or deduplication keys omit tenant or environment. Two principals can overwrite or retrieve the same logical key even though application records carry separate owners.
|
||||||
|
|
||||||
|
**Policy and query disagreement**
|
||||||
|
Row-level policy, ORM default scopes, authorization filters, and raw/bypass clients apply different predicates. Check joins, aggregates, aliases, views, transactions, `unscoped` or service clients, and error paths where context is missing.
|
||||||
|
|
||||||
|
**Blob and signed-reference overreach**
|
||||||
|
Object keys, attachment IDs, version IDs, shared links, or signed URLs permit operations or namespaces beyond the issuing principal's access, or remain valid after the underlying ACL changes. Bind operation, exact object/version, audience, expiry, and tenant.
|
||||||
|
|
||||||
|
## Derived-data and disclosure attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Search, cache, and index ACL drift**
|
||||||
|
A primary record's ACL or lifecycle changes without invalidating a searchable, cached, embedded, thumbnail, RSS, preview, or index copy. Validate filtering at retrieval time as well as document ingestion and invalidation.
|
||||||
|
|
||||||
|
**Analytics, logs, traces, and diagnostics as alternate readers**
|
||||||
|
Private content or credentials are emitted into systems with broader access, longer retention, or tenant mixing. Confirm the data class and realistic reader; field names, public identifiers, and operator-only content under intended policy are not enough.
|
||||||
|
|
||||||
|
**Enumeration and aggregate oracles**
|
||||||
|
Counts, filters, ordering, errors, unique constraints, timings, notification behavior, or existence checks disclose protected object or account state. Require a concrete confidential predicate and observable distinction, not general response variance.
|
||||||
|
|
||||||
|
## Export, backup, restore, and migration attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Export and backup scope expansion**
|
||||||
|
An export, snapshot, portability package, report, or backup includes other tenants, inaccessible object fields, soft-deleted data, secret values, or history above the requester's access. Check per-item authorization after selection and authorization to download the final artifact.
|
||||||
|
|
||||||
|
**Import and restore authority expansion**
|
||||||
|
Restore/import bypasses owner, schema, ACL, uniqueness, or validation rules, overwrites existing resources, or recreates records in a tenant the requester cannot write. Validate archive contents as untrusted and authorize the resulting operation rather than trusting prior provenance.
|
||||||
|
|
||||||
|
**Migration default and ownership confusion**
|
||||||
|
Old records lack tenant/ACL/lifecycle fields, incompatible IDs collide, or partial rollout makes new and old readers apply different defaults. Review backfill, dual-read/write, compatibility, rollback, and resumed-migration paths.
|
||||||
|
|
||||||
|
**Backup and replication boundary drift**
|
||||||
|
Encryption keys, storage accounts, cross-region replicas, restoration environments, or support snapshots have broader identity or tenant scope than primary data. Source can confirm only in-repo policy; hosted access and retention require `needs_validation`.
|
||||||
|
|
||||||
|
## Deletion, revocation, and lifecycle attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Soft-delete and tombstone bypass**
|
||||||
|
Direct lookup, search, relation traversal, object link, background processor, or restore ignores the lifecycle predicate and returns or acts on a deleted/revoked record. Check whether soft-deleted identifiers can be re-registered before all references are gone.
|
||||||
|
|
||||||
|
**Stale authorization and derived copy use**
|
||||||
|
Membership removal, ACL update, consent withdrawal, secret revocation, or role downgrade does not invalidate sessions, caches, subscriptions, jobs, or materialized data that continue to authorize future operations.
|
||||||
|
|
||||||
|
**Retention and queued-work overrun**
|
||||||
|
Deletion completes in primary storage while queued processors, retries, exports, analytics, or generated artifacts recreate or retain the data beyond the promised boundary. Find idempotent deletion and tombstone propagation.
|
||||||
|
|
||||||
|
**Restore reintroduces invalid state**
|
||||||
|
Backup, undo, undelete, or replica recovery restores data, credentials, memberships, or permissions that current policy no longer allows. Re-authorize restored state and reapply lifecycle changes made after the snapshot.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Pick one protected record and draw primary write, query, cache, index, event, export, backup, deletion, and restore paths. Mark principal and tenant at every edge.
|
||||||
|
- Compare two dummy tenants through the same local service methods, then repeat after ACL change, deletion, account switch, and restore. Do not use real user data.
|
||||||
|
- Start at bypass clients, background jobs, migrations, global uniqueness, and cache keys. These paths commonly omit request-scoped identity that interactive endpoints carry.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Name attacker or lower-trust principal, protected data/state, affected owner/tenant, alternate copy or operation, and unauthorized disclosure or mutation.
|
||||||
|
2. Cite both intended source-of-truth policy and the path that omits or disagrees with it. Confirm another layer does not enforce the same tenant/lifecycle condition.
|
||||||
|
3. Use local dummy tenants and non-sensitive fixtures to prove cross-scope access or stale lifecycle behavior. Stop at the minimum observable record or operation.
|
||||||
|
4. If external cache, object storage, replicas, analytics, backup, or retention policy is required, classify `needs_validation` and state the owner-observed check.
|
||||||
|
5. Return `confirmed` only with complete lineage and concrete boundary impact. Return `needs_validation` with the exact unresolved storage, ACL, invalidation, retention, or restore fact.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Desktop, Mobile, and Local IPC Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when the target is a desktop or mobile app, privileged helper, updater, local daemon, webview host, deep-link handler, browser native-messaging host, or local IPC client/server. Relevant untrusted actors may be a downloaded document, remote web content, another local app, another OS user, a sandboxed process, or a lower-privilege account. State that starting capability instead of treating all local users as equivalent.
|
||||||
|
|
||||||
|
Use `CLIENT-SIDE.md` for browser-side webview behavior, `MEMORY-SAFETY-AND-BINARY.md` for native memory and loader safety, and `SUPPLY-CHAIN-AND-RELEASE.md` for update authenticity.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- Establish the realistic local or remote-content attacker: another app, another OS user, a sandboxed child, an untrusted document, or a remote origin. Self-harm within the same account and authority is not a boundary violation.
|
||||||
|
- Paths, process names, bundle/package IDs, and claimed sender fields are not peer authentication. Use OS peer credentials, code identity, capability handles, or protected channel state.
|
||||||
|
- The native bridge or helper must authorize each operation and final resource after parsing. A trusted UI or broker does not make attacker-influenceable arguments trusted.
|
||||||
|
- OS sandbox, signing, entitlements, permissions, keychain ACLs, exported-component policy, and prompt behavior are real controls when pinned and visible.
|
||||||
|
- Use `confirmed` for source evidence plus bounded local/emulator tests. Use `needs_validation` when signing, manifest merge, OS version, device policy, installer ACL, or packaging is required but not observable.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deep-link, callback, and navigation attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Custom-scheme and deep-link ambiguity**
|
||||||
|
Another app or page can invoke a route that mutates state, imports data, completes authentication, or selects an account without a current-session and one-time callback binding. Review URI normalization, duplicate query fields, scheme/host/path matching, exported activity/handler policy, and stale/replayed links.
|
||||||
|
|
||||||
|
**App and account handoff confusion**
|
||||||
|
OAuth, SSO, magic-link, invite, device pairing, passwordless, or payment callbacks return to the wrong installed app, profile, tenant, or pending transaction. Bind state to the initiating app identity, current session, account, provider, operation, and expiry.
|
||||||
|
|
||||||
|
**File-open and intent authority confusion**
|
||||||
|
An associated file, share intent, drag/drop item, pasteboard/clipboard record, notification action, or open-file event triggers a privileged operation without confirming content type, sender trust where applicable, current user intent, and final target.
|
||||||
|
|
||||||
|
## Webview and native-bridge attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Navigation-origin to bridge confusion**
|
||||||
|
Remote or attacker-controlled frames can reach a JavaScript/native bridge intended only for packaged content. Validate origin at call time and after every navigation, redirect, subframe creation, popup, and error/fallback page. URL-prefix checks and initial-load checks are insufficient.
|
||||||
|
|
||||||
|
**Over-broad native bridge capabilities**
|
||||||
|
Web content can select arbitrary files, commands, IPC methods, credentials, or system actions through a generic bridge. Check method allowlists, normalized arguments, user/tenant authority, gesture/confirmation requirements, and return-value disclosure.
|
||||||
|
|
||||||
|
**Webview file and universal access**
|
||||||
|
Remote content can read app-local files, privileged custom schemes, or internal origins because file access, universal access, mixed content, debug interfaces, or custom protocol handlers join origins unexpectedly. Missing a restrictive setting without reachable protected content is hardening.
|
||||||
|
|
||||||
|
## Local IPC and exported-component attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**IPC peer-authentication gaps**
|
||||||
|
Unix sockets, named pipes, XPC, Binder, D-Bus, native messaging, RPC, shared memory, or loopback listeners accept a lower-trust peer without checking OS credentials, code identity, sandbox token, or channel ownership. Require a meaningful method or disclosure behind the channel.
|
||||||
|
|
||||||
|
**Claimed principal versus channel identity**
|
||||||
|
The authenticated process/channel belongs to one app or user, but request fields select another user, tenant, profile, or capability. Bind each method and resource to the peer credential rather than a caller-declared identifier.
|
||||||
|
|
||||||
|
**Exported service, activity, receiver, or provider overreach**
|
||||||
|
A mobile component or local automation endpoint is externally invokable and performs an operation intended for the app itself. Review final merged manifests, intent filters, permission/signature level, path grants, and alternate aliases. Manifest status unknown after packaging requires `needs_validation`.
|
||||||
|
|
||||||
|
**IPC lifecycle and correlation confusion**
|
||||||
|
Predictable request IDs, reused handles, stale channels, inherited descriptors, world-writable socket paths, or restart behavior lets one peer answer, cancel, or reuse another peer's operation. Review creation permissions and cleanup of socket files, locks, ports, and shared mappings.
|
||||||
|
|
||||||
|
## Privileged-helper and local-file attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Privileged helper as confused deputy**
|
||||||
|
A low-privilege caller can select a privileged command, file, service, user, or system setting without per-operation authorization. Review sudo/polkit/UAC/XPC helper rules and ensure the helper independently validates normalized arguments.
|
||||||
|
|
||||||
|
**Install, update, and repair path trust**
|
||||||
|
A privileged installer/helper reads manifests, scripts, packages, symlinks, working directories, or repair state writable by a lower-trust actor after authorization. Bind authorization to immutable content and safe destination paths.
|
||||||
|
|
||||||
|
**Local file ownership and TOCTOU**
|
||||||
|
The app checks a file/path then follows replacement, symlink, mount, or case/normalization changes during a privileged read/write. Use descriptor-relative operations and verify final ownership. Focus `MEMORY-SAFETY-AND-BINARY.md` on parsing after the file is opened.
|
||||||
|
|
||||||
|
**Credential-store and local-secret boundary mismatch**
|
||||||
|
A keychain/keystore item, token file, backup, log, clipboard, notification preview, or local config is readable by another app/profile/user with less authority. Plaintext readable only by the same intended OS account is not automatically a vulnerability; state the lower-trust reader and credential power.
|
||||||
|
|
||||||
|
## Application-state and device-lifecycle attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Account switch, logout, and device restore leakage**
|
||||||
|
Cached data, background tasks, widgets, notifications, local databases, webview storage, or biometric approvals survive logout/account change and appear under a later account. Review backup/restore and multi-profile behavior.
|
||||||
|
|
||||||
|
**Pending-action and user-presence confusion**
|
||||||
|
Notification, widget, shortcut, share sheet, biometric prompt, or deferred operation authorizes a different action than displayed, executes after expiry, or uses another profile's pending state. Bind confirmation to normalized action, resource, account, and current foreground state.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Enumerate every process, app component, local endpoint, URI scheme, file association, webview origin, and helper. Record OS identity, runtime privilege, caller, and callable operation.
|
||||||
|
- Read final packaging inputs: merged manifest, entitlements, installer rules, native-messaging registration, protocol handlers, and ACL creation. Source declarations can be overwritten downstream.
|
||||||
|
- Validate with dummy profiles and non-sensitive local fixtures on an isolated machine/emulator. Do not interact with other users' apps, credentials, or production services.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Name the attacker starting capability, OS/app principal crossed, entry channel, accepted argument or state, and unauthorized operation or disclosure.
|
||||||
|
2. Confirm OS sandbox, peer credential, signing, entitlement, permission, user-consent, and installer controls that apply. Unknown packaging/runtime facts require `needs_validation`.
|
||||||
|
3. For webview bridges, cite both navigation/origin control and privileged native sink. For IPC, cite peer authentication and per-resource authorization. For helpers, verify final normalized destination.
|
||||||
|
4. Keep local tests bounded and use dummy content/accounts. Stop after proving the boundary result; do not extend proof into persistence or broader system modification.
|
||||||
|
5. Return `confirmed` only with a complete source and local evidence chain. Return `needs_validation` with the exact OS, manifest, signing, ACL, or device-lifecycle fact required.
|
||||||
251
.agents/skills/security-audit/HUNTING.md
Normal file
251
.agents/skills/security-audit/HUNTING.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# Vulnerability Hunting
|
||||||
|
|
||||||
|
### Phase 2: Run coverage-led hunting waves
|
||||||
|
|
||||||
|
The parent assigns `planned` ledger units to `general` agents. Use enough focused hunters to cover the units without combining unrelated boundaries. One hunter may own closely related units in one subsystem; no unit may be silently unassigned because of an agent-count limit — a unit the budget cannot reach is explicitly `deferred` with reason `budget_cannot_reserve_critics_and_validation`.
|
||||||
|
|
||||||
|
When a budget or profile caps hunter count, assign units in priority order and record the ordering rationale in the ledger. Rank by: (1) unauthenticated or lowest-trust entry surfaces before authenticated ones; (2) boundaries protecting the most valuable resources (credentials, cross-tenant data, code execution, release authority); (3) prior-run gaps, revalidation targets, and changed source before same-source re-passes; (4) units whose class historically yields confirmed findings for this target type over speculative ones. Ties break lexicographically by `coverage_id` so runs stay deterministic.
|
||||||
|
|
||||||
|
Before launch, the parent changes assigned units to `in_progress`, sets a canonical lowercase `agent_id`, and creates that agent's `scratch/` and parent-owned `artifacts/`. Hunters read source and parent-provided context, write only to their unique `scratch/`, and return one structured result through the Task tool. They never write retained artifacts or edit target source, `architecture.md`, `coverage-ledger.json`, `findings.json`, or another agent's files.
|
||||||
|
|
||||||
|
## Required hunter prompt
|
||||||
|
|
||||||
|
Every hunter prompt contains these parts in this order:
|
||||||
|
|
||||||
|
1. A two-sentence role preamble: the hunter's goal is to find source-grounded security invariant failures in its assigned units, and it must return exactly one JSON object matching the structured-result contract at the end of this prompt.
|
||||||
|
2. `architecture.md` verbatim.
|
||||||
|
3. Assigned coverage IDs, subsystem, boundary, repository-relative starting paths, and each unit's assignment block map from `coverage-ledger.json`.
|
||||||
|
4. The exact selected blocks, copied verbatim: each selected ordinary attack-class block from `ATTACK-CLASSES.md`, and from each selected companion its `Core discipline`, each chosen attack-class subsection, `Universal moves`, and `Validation rules`. Ordinary blocks are self-contained and carry no companion-style `Core discipline`, `Universal moves`, or `Validation rules` sections. Do not send block or companion names alone.
|
||||||
|
5. Explicit excluded ordinary and companion blocks with a reason for each exclusion.
|
||||||
|
6. The core hunting method below, followed by the promotion procedure block.
|
||||||
|
7. The core validation rules below.
|
||||||
|
8. Carried same-source prior confirmed exclusions, each limited to fingerprint, title, and root cause, plus peer-owned current coverage IDs that this hunter must not duplicate.
|
||||||
|
9. The unique scratch/artifact paths, safe agent ID, predeclared promotion allowlist and byte limits, and the structured-result contract, including the Structured hunter result block below and the `confirmed` and `needs_validation` branches of `report-schema.json` copied verbatim.
|
||||||
|
|
||||||
|
A prompt may select several companion blocks when the same path crosses several domains. Keep their constraints together. Scope is the hunter's coverage obligation, not permission to duplicate excluded work. If an unexpected different boundary appears, return it under `uncovered` so the parent creates a stable ledger unit and assigns it in the next wave.
|
||||||
|
|
||||||
|
#### Core hunting method — include in every hunter prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Defensive vulnerability-finding method
|
||||||
|
|
||||||
|
Your goal is to find source-grounded security invariant failures and the smallest fix,
|
||||||
|
not to expand harm beyond the boundary result. Stay within source review and bounded local execution.
|
||||||
|
Do not contact deployed endpoints, provider APIs, registries, identity systems,
|
||||||
|
message brokers, shared services, or other users. Use local dummy data only.
|
||||||
|
|
||||||
|
READ THE CODE AT DEPTH. Follow each assigned input through parsing, identity,
|
||||||
|
authorization, normalization, state, derived copies, and the final sink. Read sibling,
|
||||||
|
legacy, batch, retry, cancellation, migration, and error paths that produce the same
|
||||||
|
effect. Compare sibling controls for equivalence, not only presence, and compare what
|
||||||
|
one component guarantees with what the next component assumes.
|
||||||
|
|
||||||
|
WORK FROM A CONCRETE INVARIANT:
|
||||||
|
1. Name the lower-trust principal and starting capability.
|
||||||
|
2. Name the accepted value, action, state transition, or resource selector.
|
||||||
|
3. Locate the control that should reject, bind, isolate, limit, or revoke it.
|
||||||
|
4. Trace the exact source path after that decision.
|
||||||
|
5. Stop at the smallest affected dummy record, wrong return value, process-integrity
|
||||||
|
effect, or locally observable shared-resource effect.
|
||||||
|
6. State a source-level change and regression case that enforce the invariant.
|
||||||
|
|
||||||
|
DEPTH BOUND: trace only paths that can reach your assigned boundary or whose
|
||||||
|
guarantees that boundary relies on. Stop a line of investigation as soon as the
|
||||||
|
invariant is settled either way, and record the result in your structured output —
|
||||||
|
a covered, candidate, or blocked disposition, or an `uncovered` entry — instead of
|
||||||
|
continuing to search.
|
||||||
|
|
||||||
|
TEST SAD PATHS AND DISAGREEMENTS. Check absent, empty, zero, negative, maximum,
|
||||||
|
over-limit, duplicate, mixed encoding, stale, revoked, reordered, concurrent,
|
||||||
|
partially migrated, failed dependency, and rollback state only where the interface
|
||||||
|
accepts them. Compare canonicalization and units at every parser or policy handoff.
|
||||||
|
For multi-step issues, treat each output as a prerequisite and do not assume a later
|
||||||
|
boundary. If any prerequisite is not established, record a blocker.
|
||||||
|
|
||||||
|
When a proposed high or critical candidate reveals a reusable root cause, search paths
|
||||||
|
owned by the assigned coverage IDs for lexical, structural, and logical variants.
|
||||||
|
Consolidate the same root cause, but establish each variant's conditions and impact
|
||||||
|
independently. Do not investigate peer-owned units. Return a variant with no current
|
||||||
|
coverage unit as `uncovered`.
|
||||||
|
|
||||||
|
USE THE NARROWEST LOCAL CHECK THAT SETTLES THE CLAIM. Target-controlled builds,
|
||||||
|
tests, processes, browsers, emulators, fuzzers, and fixture processing may run only
|
||||||
|
inside the parent-approved OS-enforced sandbox. It must disable external networking,
|
||||||
|
start from an empty allowlisted environment, expose target and tools read-only, permit
|
||||||
|
writes only to your scratch directory, and apply low CPU, memory, process, file-size,
|
||||||
|
disk, and wall-clock limits. Isolated loopback is allowed only for a local fixture.
|
||||||
|
If any control is unavailable, do not execute: return needs_validation with that exact
|
||||||
|
blocker. Prefer an existing unit test, minimal function harness, dummy-tenant service
|
||||||
|
call, small malformed fixture, deterministic race schedule, or locally rendered policy.
|
||||||
|
Do not install or fetch tools.
|
||||||
|
|
||||||
|
Record the exact input, command, limits, and minimum result. For the environment,
|
||||||
|
record only allowlisted variable names and safe non-secret values needed to reproduce
|
||||||
|
the check. Never capture the ambient environment, inherited variables, credentials,
|
||||||
|
authentication state, or unrelated host paths. The target-controlled process writes
|
||||||
|
only in scratch. After the sandbox and all its processes terminate, only trusted
|
||||||
|
parent-side code may promote predeclared scratch-relative files, following the
|
||||||
|
promotion procedure block included verbatim in this prompt. You and target code never
|
||||||
|
write retained artifacts. If promotion is unavailable or fails for decisive evidence,
|
||||||
|
return needs_validation with the exact promotion blocker.
|
||||||
|
Never stress availability, invoke a live target, use a real credential, publish an
|
||||||
|
artifact, or continue past the minimum observed effect.
|
||||||
|
|
||||||
|
A deployment, browser, provider, broker, OS, proxy, package, secret, or identity fact
|
||||||
|
outside source is not proof either way. If one such fact is decisive, return a
|
||||||
|
needs_validation record with the exact missing observation and safe owner-observed check.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Promotion procedure — copy this promotion procedure verbatim into every hunter prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
Artifact promotion procedure (trusted parent-side code only):
|
||||||
|
Reference only for you: the parent performs these steps; you never perform them.
|
||||||
|
|
||||||
|
Before execution, the parent opens and retains trusted, non-inheritable directory
|
||||||
|
descriptors for the agent's scratch/ and artifacts/ roots, and records an allowlist
|
||||||
|
of expected scratch-relative artifact files plus explicit per-file and cumulative
|
||||||
|
byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox
|
||||||
|
and all its processes terminate, trusted parent-side code promotes each allowlisted
|
||||||
|
file separately:
|
||||||
|
|
||||||
|
1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or
|
||||||
|
symlinked components.
|
||||||
|
2. Walk each parent component from the retained scratch-root descriptor with
|
||||||
|
no-follow directory-relative operations; never reopen by path.
|
||||||
|
3. Open the leaf no-follow and nonblocking.
|
||||||
|
4. Verify with `fstat` that it is a regular file with link count exactly one and
|
||||||
|
within the recorded per-file and cumulative byte limits.
|
||||||
|
5. Enforce those limits again while reading from that descriptor.
|
||||||
|
6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity,
|
||||||
|
type, link count, or size.
|
||||||
|
7. For the destination, walk every parent component from the retained
|
||||||
|
artifacts-root descriptor with no-follow directory-relative operations; require
|
||||||
|
each existing component to be a real directory, and create any missing directory
|
||||||
|
exclusively before reopening and verifying it no-follow.
|
||||||
|
8. Create the leaf exclusively without following links, verify that the opened
|
||||||
|
destination is a regular file with link count exactly one, and copy from the
|
||||||
|
verified source descriptor without reopening either path.
|
||||||
|
9. Use equivalent race-safe APIs on non-POSIX systems.
|
||||||
|
10. Never recursively copy or glob scratch, extract an archive into artifacts, or
|
||||||
|
open or promote a symlink, FIFO, socket, device, directory, hard-linked file,
|
||||||
|
changing file, or file that exceeds its bound.
|
||||||
|
11. If any check is unavailable, cannot be enforced, or fails, discard the scratch
|
||||||
|
entry; if it is decisive evidence, retain `needs_validation` with the exact
|
||||||
|
promotion blocker.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Core validation rules — include in every hunter prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Candidate gate
|
||||||
|
|
||||||
|
1. A candidate needs a complete repository-relative source trace and evidence for the
|
||||||
|
claimed root cause, including the strongest source-visible control.
|
||||||
|
2. A proposed confirmed record needs a bounded local observed result, meaningful impact
|
||||||
|
across a stated boundary, complete conditions, and no visible preventing layer.
|
||||||
|
3. Do not strengthen a crash into code execution, ordinary work into shared availability,
|
||||||
|
or a same-principal action into privilege gain.
|
||||||
|
4. If a required fact is not source-visible or locally observable, use
|
||||||
|
needs_validation. Name exact blockers; do not give it severity or speculative completion.
|
||||||
|
5. A missing best practice with no affected principal/resource is excluded or hardening,
|
||||||
|
not a finding. A candidate disproved by source is not needs_validation.
|
||||||
|
6. Use the same source-derived fingerprint for the same root cause in every state.
|
||||||
|
It must match `^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$` and must not include a line,
|
||||||
|
wave, agent, severity, or verdict.
|
||||||
|
7. Return an empty candidate array when nothing survives these gates.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local validation boundaries
|
||||||
|
|
||||||
|
Local execution is for confirmation, not impact expansion:
|
||||||
|
|
||||||
|
- **Allowed only in the required OS sandbox:** offline builds with present dependencies; isolated-loopback processes using dummy state; unit and integration tests; small fixture processing; sanitizers; bounded fuzz/regression tests; deterministic concurrency checks; local browser/emulator tests with dummy accounts; rendered manifests and policy evaluation with dummy identities; mocked external or paid calls.
|
||||||
|
- **Disallowed:** live or deployed traffic; requests to services not started for this isolated check; network dependency installation; real accounts or credentials; production data; shared queues, cloud resources, runners, registries, signing or release services; publishing; stress, saturation, or cost generation; any work after the minimum dummy-data boundary result.
|
||||||
|
|
||||||
|
The sandbox starts with an empty environment, gives target code no external network or host writable path, and enforces explicit low resource and time limits for every check, not only checks expected to be expensive. Scratch output remains target-controlled after exit. Promote it only with the no-follow, path-confined, regular-file, bounded-size host procedure in `SKILL.md`. Missing any sandbox or promotion capability does not erase a source-grounded candidate; represent the exact blocker in `needs_validation`.
|
||||||
|
|
||||||
|
## Structured hunter result
|
||||||
|
|
||||||
|
Return exactly one JSON object, with no surrounding prose:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"units": [
|
||||||
|
{
|
||||||
|
"coverage_id": "one assigned ID",
|
||||||
|
"disposition": "covered|candidate|blocked",
|
||||||
|
"reviewed_paths": ["repo/relative/path"],
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"agent_id": "canonical owner of this check",
|
||||||
|
"reviewed_paths": ["repo/relative/path owned by this check"],
|
||||||
|
"invariant": "specific control checked for this unit",
|
||||||
|
"method": "source|local",
|
||||||
|
"result": "what source or the bounded check established",
|
||||||
|
"artifact": "agents/<agent-id>/artifacts/file for local, null for source"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"candidate_fingerprints": [],
|
||||||
|
"unresolved": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"candidates": [],
|
||||||
|
"hardening": ["concrete non-finding note"],
|
||||||
|
"uncovered": [
|
||||||
|
{
|
||||||
|
"surface": "...",
|
||||||
|
"boundary": "...",
|
||||||
|
"subsystem": "...",
|
||||||
|
"attack_class": "...",
|
||||||
|
"starting_paths": ["repo/relative/path"],
|
||||||
|
"reason": "why this needs its own deterministic coverage unit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `candidates` entry is schema-shaped except that it uses `proposed_verdict` in place of `verdict`:
|
||||||
|
|
||||||
|
- `proposed_verdict: "confirmed"`: include every field required by the `confirmed` branch of `report-schema.json` other than `verdict`: `fingerprint`, title, description, `root_cause`, `intended_behavior`, ordered `trace`, `evidence`, `conditions`, target-neutral `execution`, `remediation`, `severity`, and `confidence`. The execution instructions describe only the bounded local check already performed. `payloads` holds the minimum test input, fixture, or native invocation. `observed_result` records actual local output. Overall severity must not exceed observed impact.
|
||||||
|
- `proposed_verdict: "needs_validation"`: include every field required by that schema branch other than `verdict`: `fingerprint`, title, description, `claimed_root_cause`, ordered `trace`, `evidence`, nonempty `blockers`, and `validation_plan` with at least one applicable `local` or `deployment` step. Do not invent an inapplicable context. Do not include severity, execution, remediation, reason, or a confirmed `root_cause`. `deployment` is an owner-observed check, not a request to probe a live target.
|
||||||
|
|
||||||
|
Every assigned coverage ID appears exactly once in `units`. A `covered` unit needs an owner, nonempty `reviewed_paths` and `checks`, no unresolved fact, and no candidate. A `candidate` unit has the same owned evidence and is the only state that carries linked fingerprints. A `blocked` unit is an owned partial review with nonempty paths, checks, and unresolved facts but no fingerprint. All source paths are repository-relative, never absolute or traversal paths. A trace with several entries begins at `entrypoint`, ends at `sink`, and labels intermediate steps `propagation`. Every check has its own canonical lowercase `agent_id` and nonempty `reviewed_paths`; the unit-level list is exactly the union of those owned paths. A `source` check uses `artifact: null`. A `local` check uses one successfully parent-promoted regular file beneath `agents/<check.agent_id>/artifacts/`; this permits a verifier to add independently owned evidence without taking ownership from the hunter. Never link scratch, an output-root file, or another check owner's artifact.
|
||||||
|
|
||||||
|
## Parent consolidation and ledger update
|
||||||
|
|
||||||
|
The parent validates each unit result, maps it to exactly one assigned `coverage_id`, and updates only that ledger unit. Reject duplicate or absent IDs, unsafe unit or check agent IDs, source checks with artifacts, and local artifacts that trusted parent-side code did not promote into the check owner's artifacts subtree. Copy the unit's `reviewed_paths`, its `checks` into the unit's `local_checks`, linked artifact paths, candidate fingerprints, and unresolved facts into the ledger. Retain each hunter's `hardening` list in a parent bookkeeping field on the relevant units (outside the semantic fields) so Phase 6 can report it. A failed, malformed, or unsupported conclusion leaves that unit `planned` for reassignment. Untouched budget/profile units become unassigned `deferred` units with empty evidence and a reason; do not hide partial evidence in `deferred`. Run `validate-coverage-ledger.cjs` after the update; an invalid ledger cannot drive another assignment. This per-unit contract allows one hunter to close one unit while returning a candidate or blocker for another.
|
||||||
|
|
||||||
|
Consolidate candidate entries by fingerprint and then by root cause. One root cause that exposes several entry paths or effects is one candidate with the strongest complete trace. Related but independent missing controls use separate fingerprints. Record duplicate fingerprints in the relevant ledger unit and do not send duplicate candidates to validation.
|
||||||
|
|
||||||
|
## Coverage-critic waves
|
||||||
|
|
||||||
|
Immediately after each hunter wave, spend the reserved invocation on one fresh `research` post-wave coverage critic. It receives `architecture.md`, the full coverage ledger including each assignment block map, current candidate fingerprints and states, and the prior-ledger gap summary. It reads source but does not write or run targets. Require exactly this JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"missing_units": [
|
||||||
|
{
|
||||||
|
"surface": "...",
|
||||||
|
"boundary": "...",
|
||||||
|
"subsystem": "...",
|
||||||
|
"attack_class": "...",
|
||||||
|
"starting_paths": ["repo/relative/path"],
|
||||||
|
"selected_companion_blocks": ["FILE.md#section"],
|
||||||
|
"excluded_blocks": [{"block": "FILE.md#section", "reason": "..."}],
|
||||||
|
"reason": "source-backed coverage gap"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reassign_ids": ["existing-id-that-did-not-close"],
|
||||||
|
"resolved_prior_leads": ["fingerprint"],
|
||||||
|
"stop": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The critic checks for unmapped entry points, unchecked parallel paths, missing lifecycle modes, selected companion classes without a unit, unjustified exclusions, units closed without paths/checks, and prior `needs_validation` or changed-source gaps that no unit addresses. It proposes coverage, not findings. `stop` is the critic's own assessment: `true` only when it accepts no `missing_units` and no `reassign_ids`; the parent's loop condition below, not `stop` alone, decides whether another wave runs. For each fingerprint in `resolved_prior_leads`, the parent marks the linked unit or prior-lead entry resolved and records the critic's source-backed reason.
|
||||||
|
|
||||||
|
The parent rejects proposed units outside the review scope or source/local boundary, derives canonical IDs for accepted units, and deduplicates them against current units. A prior same-source completed unit may supply evidence; prior `deferred`, `blocked`, `out_of_scope`, or changed-source units become current work and never suppress an accepted unit. Fail rather than merge a canonical ID collision. For each legitimate `reassign_id` with live `blocked`, `covered`, or `candidate` evidence, append that exact terminal record to the unit's `attempts` with the critic's source-backed `reassignment_reason`. Preserve its owner, checks, artifacts, fingerprints, and unresolved facts in that archive. Increment the live `wave`; the next hunter must be a fresh owner and receives an `in_progress` unit with empty live evidence. The hunter's terminal result writes only its new evidence into the live fields. Never copy an archived owner's checks or artifacts into the new live attempt. Sort IDs and validate the ledger before another assignment. In `standard` and `deep`, when the post-wave critic reports no accepted `missing_units` or legitimate `reassign_ids` and no `planned` units remain, spend the separately reserved invocation on a distinct final-clean critic. Complete coverage only when that critic also returns no accepted work. If it finds work, queue it and repeat the wave, post-wave critic, and final-clean process. If time or resources force an early stop, mark every untouched unit `deferred`, preserve the critic's reason, and disclose the gap in the report. Never use a silent wave or agent cap as evidence of complete coverage.
|
||||||
|
|
||||||
|
The run profile bounds this loop. A `quick` run has exactly one hunter wave followed by exactly one final critic pass. Add each accepted `missing_unit` to the current ledger and mark it `deferred` with reason `quick_profile_final_critic`. For each legitimate evidence-bearing `reassign_id`, archive the live terminal state in `attempts`, increment `wave`, and set the live state to unassigned `deferred` with empty evidence and reason `quick_profile_final_critic`. Do not launch a second hunter wave or another critic. In a scoped run, the critic still reports out-of-scope gaps it notices, but the parent records them as `out_of_scope` with the critic's reason instead of assigning them. The early-stop rule above is the same mechanism: `quick` is a pre-declared early stop, not evidence of complete coverage.
|
||||||
|
|
||||||
|
A budget bounds it the same way. Before assigning each wave, compare remaining budget against its hunter count, the validation reserve, the immediate post-wave critic, and the retained final-clean critic (`quick` reserves only its single final post-wave critic). Shrink the hunter wave to fit, taking units in priority order. If those mandatory reserves do not fit, launch no hunter from that wave and mark its planned units `deferred` with reason `budget_cannot_reserve_critics_and_validation`. Critic-proposed units enter the same ranked queue rather than extending the budget. If surviving candidates exceed the validation reserve, follow the incomplete-run rule in `SKILL.md`: stop hunting, validate in fingerprint order, retain unvalidated units as unresolved candidates, and never present them as findings.
|
||||||
101
.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md
Normal file
101
.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Memory Safety, Binary, and Kernel Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when the target processes untrusted bytes in a memory-unsafe or privileged context: C/C++/Objective-C, Rust `unsafe`, FFI, kernel modules and drivers, parsers and decoders, network daemons, firmware, binary loaders, language runtimes, and JITs. Use `PROTOCOLS-RPC-AND-MESSAGING.md` for protocol authorization and state-machine logic, and this file for process integrity, memory safety, ABI boundaries, and loader behavior.
|
||||||
|
|
||||||
|
Pick relevant classes from Phase 1 and split large targets by parser, allocator/lifetime, FFI, concurrency, loader, runtime, or privileged interface.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- Re-derive every bound and lifetime from attacker-controlled inputs and all callers. Validate against the worst accepted case, not a typical test vector.
|
||||||
|
- A panic, sanitizer finding, or crash proves a defect only when a realistic untrusted input reaches it. Do not infer memory corruption, code execution, or shared availability impact from a label alone.
|
||||||
|
- Validate in a local harness with sanitizers, deterministic concurrency tests, existing fuzz targets, and debugger-assisted fault classification. Stop after proving the violated invariant and observable impact; do not develop post-corruption techniques.
|
||||||
|
- Assembly, JIT code, custom allocators, intra-object accesses, and foreign libraries can escape sanitizer coverage. Identify which relevant instructions are instrumented.
|
||||||
|
- Classify as `confirmed` only after source evidence and bounded local validation establish the defect and effect. Use `needs_validation` when ABI, allocator, architecture, feature, deployment, or reachability facts remain unknown.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bounds, integer, and representation attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Out-of-bounds read or write**
|
||||||
|
A length, offset, index, or terminator reaches a fixed or allocated buffer without a correct bound. Recalculate available headroom after prefixes, alignment, padding, and terminators. Check both source and destination capacity, and whether a short input is read before its declared length is trusted.
|
||||||
|
|
||||||
|
**Integer overflow, underflow, truncation, and signedness**
|
||||||
|
Review attacker-controlled arithmetic before allocation, copy, loop, indexing, and pointer operations. High-hit patterns include `a - b` with `b > a`, `count * element_size`, additions near the type maximum, negative values converted to unsigned, 64-bit lengths narrowed to 32-bit fields, and sentinel values such as `-1` becoming a large size. Confirm which checked representation is later used.
|
||||||
|
|
||||||
|
**Unit and pointer-depth confusion**
|
||||||
|
Code mixes bytes, elements, code units, pages, words, wire units, or nested pointer element sizes. Compare the unit at parse, validation, allocation, API boundary, and copy. A bounds check using the same wrong unit as the allocation is still wrong.
|
||||||
|
|
||||||
|
**Uninitialized or partially initialized data**
|
||||||
|
A buffer, padding, struct field, or vector capacity is returned, compared, hashed, serialized, or passed across a trust boundary before initialization. Require an observable consumer and realistic output length; stack allocation by itself is not disclosure.
|
||||||
|
|
||||||
|
## Lifetime, type, and concurrency attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Use-after-free, stale view, and double free**
|
||||||
|
Owners are released while callbacks, wait queues, timers, iterators, borrowed slices, or cached raw pointers can still use them. Review every error, cancellation, close, and realloc path. For embedded notification anchors, each free path must drain or detach all observers.
|
||||||
|
|
||||||
|
**Type confusion and invalid downcast**
|
||||||
|
A tag, vtable, union discriminator, object kind, or foreign handle is checked differently from the representation later read. Look for unchecked dynamic casts, stale tags after reuse, and serialized types whose validated element differs from the element consumed. Confirm a wrong-type read or write locally without extending the test beyond the violated invariant.
|
||||||
|
|
||||||
|
**Reference-count and ownership races**
|
||||||
|
Non-atomic retain/release, a check followed by an unlocked use, or inconsistent ownership across threads can free or mutate an object during access. Compare fast, error, shutdown, and compatibility paths for the same lock and ownership rules.
|
||||||
|
|
||||||
|
**Shared-state races and TOCTOU**
|
||||||
|
Concurrent parser streams, global caches, lazy initialization, signal handlers, and resource teardown can invalidate bounds, policy, or pointers established earlier. Verify the race with a repeatable local schedule, barrier, or thread sanitizer; a hypothetical interleaving without a security-relevant state transition remains `needs_validation`.
|
||||||
|
|
||||||
|
**Lock-order, deadlock, and starvation**
|
||||||
|
Externally reachable operations acquire locks in inconsistent order or hold them across callbacks and blocking I/O. Report under availability only when bounded input can stop shared progress; otherwise record it for fixing as a concurrency defect.
|
||||||
|
|
||||||
|
## FFI and ABI attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Pointer-length and ownership contract mismatch**
|
||||||
|
Caller and callee disagree on who allocates, frees, pins, or mutates a buffer, how long a pointer remains valid, or whether a length is bytes or elements. Trace both sides of every `extern`, CGo/JNI/Python/native binding, and generated wrapper. Check null, zero length, aliasing, and callback retention.
|
||||||
|
|
||||||
|
**Layout, alignment, and enum disagreement**
|
||||||
|
Foreign code receives a struct, bitfield, packed record, callback signature, integer width, enum, or calling convention that differs by architecture or build flag. Verify `repr`, packing, alignment, endianness, and ABI-specific types. An in-repo declaration mismatch can be confirmed locally; an opaque foreign implementation requires `needs_validation`.
|
||||||
|
|
||||||
|
**Unwind, exception, and thread-affinity violations**
|
||||||
|
Exceptions or panics cross an ABI that forbids unwinding, callbacks run after teardown, or APIs requiring one runtime thread are invoked elsewhere. Review error conversion and cancellation. Confirm whether the process aborts or state is corrupted before assigning impact.
|
||||||
|
|
||||||
|
## Binary loading and runtime attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Library, plugin, and executable search-order trust**
|
||||||
|
A privileged process loads a library, plugin, runtime image, or helper from a path writable by a less-trusted principal, or resolves a bare name through an attacker-influenceable working directory or environment. Compare intended installation ownership with each fallback and compatibility search path. A user loading their own plugin into their own process is not a boundary violation.
|
||||||
|
|
||||||
|
**Missing artifact identity or signature binding**
|
||||||
|
A loader verifies one file or metadata record but maps a different image because path resolution, file replacement, architecture slices, or embedded resources are not bound to the check. Supply-channel authenticity belongs in `SUPPLY-CHAIN-AND-RELEASE.md`; this class covers the local verification-to-map gap.
|
||||||
|
|
||||||
|
**Malformed binary metadata and relocation handling**
|
||||||
|
Offsets, counts, sections, relocations, symbols, bytecode, or debug metadata are trusted before range, overlap, and representation checks. Test parsers with bounded local fixtures and sanitizers. Separate memory corruption from a safely rejected malformed file.
|
||||||
|
|
||||||
|
**JIT and generated-code consistency**
|
||||||
|
Validator, interpreter, optimizer, and generated code disagree about types, bounds, side effects, or lifetime. Diff optimized and unoptimized paths using the same local input. Confirm a process-integrity effect; output variance that stays within language semantics is not a finding.
|
||||||
|
|
||||||
|
**Unload, reload, and teardown safety**
|
||||||
|
Live function pointers, callbacks, worker threads, or data views survive module unload or runtime reset. Review shutdown and failed-load cleanup as closely as startup.
|
||||||
|
|
||||||
|
## Kernel and privileged-interface attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**User-copy bounds and repeated reads**
|
||||||
|
A syscall, ioctl, driver, or kernel parser derives a trusted fact from user memory then reads the same mutable address again. Copy the full request once or revalidate the later copy. Also audit size, direction, and access checks at each user-copy primitive.
|
||||||
|
|
||||||
|
**Privileged object lifecycle and dispatch consistency**
|
||||||
|
Externally reachable objects have unbalanced retain/release, teardown without observer drain, unchecked selector/table indices, or duplicated compatibility paths that omit a guard. Diff each dispatch and free path side by side.
|
||||||
|
|
||||||
|
**Under-authorized powerful interfaces**
|
||||||
|
A device node, admin socket, helper, or management API validates shape but not the caller's authority over the resource. Establish actual interface ownership and reachability; permissions or sandbox policy outside the repository make this `needs_validation`.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Audit fixes and duplicated paths for the same source-to-sink shape. A check in one caller, architecture, protocol role, feature flag, or compatibility path does not protect its siblings.
|
||||||
|
- Build a table for every parser or FFI boundary: accepted length/type, checked representation, allocation owner, consumer, thread, and teardown. Most native findings are one disagreement in that table.
|
||||||
|
- Use existing corpora and small locally generated boundary fixtures. Save exact sanitizer/runtime output and the input property that triggers it; avoid large resource consumption and any live target.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Establish a realistic untrusted entry and exact operation that violates a bounds, type, lifetime, ABI, concurrency, loader, or authority invariant.
|
||||||
|
2. Classify the observable effect: invalid read, invalid write, stale alias, wrong object, uninitialized output, unauthorized image load, deadlock, or safe process termination. Do not claim a stronger effect than observed.
|
||||||
|
3. Run the narrowest local harness, existing test, sanitizer, or fuzzer needed to reproduce the effect. Verify sanitizer coverage of the faulting operation and record architecture/build conditions.
|
||||||
|
4. For concurrency, use a deterministic schedule or sanitizer trace. For binary loading, prove the checked identity differs from the mapped identity and name the lower-trust writer.
|
||||||
|
5. Return `confirmed` findings only with exact input, source trace, and observed result. Return `needs_validation` for a specific unresolved reachability, ABI, build, deployment, or runtime fact and state the bounded check needed.
|
||||||
81
.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md
Normal file
81
.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Protocols, RPC, and Messaging Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when the target uses gRPC, GraphQL transports, Cap'n Proto, Thrift, Protobuf, custom binary protocols, streaming RPC, webhooks, brokers, queues, pub/sub, or event buses. It covers peer identity, logical message interpretation, routing, replay, ordering, and delivery semantics. Use `MEMORY-SAFETY-AND-BINARY.md` for parser memory safety, `WEB-PROTOCOL-AND-AUTH.md` for HTTP framing, and `RESOURCE-EXHAUSTION-AND-AVAILABILITY.md` for availability impact.
|
||||||
|
|
||||||
|
Split large systems by producer/consumer pair, external/internal peer role, synchronous RPC, streaming, and asynchronous message path.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- "Internal" is not authentication. Name the peer identity at every hop and show how it becomes the application principal used for authorization.
|
||||||
|
- Schema validation proves message shape, not provenance, resource authority, ordering, or safe values. Follow decoded fields to policy and side effects.
|
||||||
|
- Broker guarantees and application guarantees differ. Write down retry, ordering, acknowledgement, deduplication, and transaction behavior before evaluating state changes.
|
||||||
|
- Parser disagreement requires two concrete consumers, schema versions, or wire representations and one security-relevant divergent value.
|
||||||
|
- Use `confirmed` for source-complete paths plus bounded local producer/consumer tests. Use `needs_validation` for broker ACL, service-mesh identity, topic attachment, or compatibility behavior outside the repository.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Framing, schema, and interpretation attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Message boundary and canonicalization disagreement**
|
||||||
|
Components disagree on length, compression, duplicate fields, unknown fields, encoding, numeric width, normalization, or envelope/body precedence. Compare generated and custom parsers, gateways, language bindings, and version converters. Confirm which principal, resource, or operation differs after decoding.
|
||||||
|
|
||||||
|
**Union, enum, and default confusion**
|
||||||
|
Unknown variants, missing discriminators, zero values, default privileges, or compatibility mappings reach code that assumes a validated case. Review exhaustive dispatch, default branches, and how old consumers interpret newly added fields.
|
||||||
|
|
||||||
|
**Envelope and payload identity mismatch**
|
||||||
|
Authorization uses trusted-looking routing or envelope metadata while the handler acts on a conflicting tenant, account, subject, object, or sender in the body. Identify which source is authoritative and ensure clients cannot override it.
|
||||||
|
|
||||||
|
## RPC identity and authorization attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Interceptor and method-path inconsistency**
|
||||||
|
An authn/authz interceptor applies to unary methods but not streams, reflection, health, gateway-transcoded paths, compatibility services, or individual stream messages. Compare every registration and route to the same operation.
|
||||||
|
|
||||||
|
**Peer identity to application-principal confusion**
|
||||||
|
mTLS, workload identity, bearer metadata, forwarded identity, or broker credentials authenticate a channel, but a caller-controlled field selects the user or tenant. The channel identity and claimed principal must be bound by deterministic policy.
|
||||||
|
|
||||||
|
**Per-item and streaming authorization gaps**
|
||||||
|
A stream, subscription, batch, or bulk message is authorized once, then later items name different resources or continue after role, membership, or token revocation. Re-check where scope can change and bind subscriptions to their original principal.
|
||||||
|
|
||||||
|
**Callback and reply-correlation confusion**
|
||||||
|
Predictable, reused, or cross-tenant correlation IDs let a response, webhook, cancellation, or acknowledgment satisfy another caller's pending operation. Bind each outstanding request to authenticated peer, tenant, operation, and lifecycle.
|
||||||
|
|
||||||
|
## Broker and queue isolation attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Topic, routing-key, and subscription scope gaps**
|
||||||
|
A publisher or subscriber can select another tenant's topic, wildcard, consumer group, partition, reply queue, or dead-letter route. Check broker-enforced ACLs where visible and application-side namespace construction. Tenant text inside a payload is not isolation.
|
||||||
|
|
||||||
|
**Dead-letter, retry, and diagnostic disclosure**
|
||||||
|
Messages routed to dead-letter queues, error topics, tracing, or operator views contain secrets or cross-tenant payloads accessible to a lower-trust consumer. Review policy and redaction at the failure path, not just normal delivery.
|
||||||
|
|
||||||
|
**Untrusted producer treated as control plane**
|
||||||
|
A message body can declare itself an admin event, provider callback, replication record, or migration instruction without an independently authenticated producer and event type. Verify signatures and source/account/audience binding before privileged handling.
|
||||||
|
|
||||||
|
## Replay, ordering, and transaction attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Duplicate delivery and idempotency gaps**
|
||||||
|
Retries or redelivery repeat a side effect because deduplication is absent, occurs after mutation, or uses a key that collides across tenants or operations. Confirm the broker's delivery model and the side effect that is not naturally idempotent.
|
||||||
|
|
||||||
|
**Out-of-order and stale message acceptance**
|
||||||
|
Older state, revoked membership, canceled work, or pre-step-up authorization arrives after newer state and overwrites it. Review sequence/version checks, tombstones, partition changes, and restore/replay workflows.
|
||||||
|
|
||||||
|
**Acknowledgment/commit ordering defects**
|
||||||
|
Acknowledgment occurs before durable commit and loses security-relevant work, or commit happens before an unreliable acknowledgment and duplicates a mutation. Evaluate transactional outbox/inbox behavior and failure recovery.
|
||||||
|
|
||||||
|
**Partial multi-consumer transitions**
|
||||||
|
Several consumers jointly implement one authorization or business transition, but retries and partial failure leave only a subset committed. Identify invariants that must become durable atomically or compensate with current authorization.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Draw producer → broker/transport → gateway → consumer → storage for each message family. At each hop record authenticated peer, authoritative tenant/resource fields, validation, and side effect.
|
||||||
|
- Feed the same small fixture to every in-repo schema version or language binding. Test duplicate, missing, unknown, boundary, replayed, and reordered messages without producing load.
|
||||||
|
- Compare normal, retry, dead-letter, replay, migration, reflection, stream, and gateway-transcoded routes. Security policy must survive transport changes.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Name the realistic producer or peer, accepted message, authenticated channel identity, affected principal/resource, and unauthorized mutation or disclosure.
|
||||||
|
2. For disagreement claims, cite both parsers/consumers and the divergent decoded value. Safe rejection by either side prevents confirmation.
|
||||||
|
3. For replay/order claims, establish actual delivery guarantees and reproduce the invariant failure with a bounded local/in-memory transport.
|
||||||
|
4. For authorization and isolation, verify all interceptor, broker ACL, gateway, and consumer layers visible in source. External attachments make the candidate `needs_validation`.
|
||||||
|
5. Return `confirmed` only with the complete message lifecycle and observed meaningful result. Return `needs_validation` with the exact broker, service identity, route, or delivery fact required.
|
||||||
156
.agents/skills/security-audit/RECONNAISSANCE.md
Normal file
156
.agents/skills/security-audit/RECONNAISSANCE.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Reconnaissance
|
||||||
|
|
||||||
|
### Phase 1: Map the source and plan coverage
|
||||||
|
|
||||||
|
The parent initializes `run-metadata.json`, applies the strict pre-reconnaissance budget gate in `SKILL.md`, then creates agent scratch roots and the shared ledger before hunting. If the gate fails, record the incomplete status in metadata and launch no reconnaissance agent. Reconnaissance reads the target and locally available build/configuration state only. It does not contact deployed endpoints, external identity providers, registries, brokers, cloud APIs, or other shared services.
|
||||||
|
|
||||||
|
Launch several `research` agents in parallel. They return structured facts to the parent and do not write files.
|
||||||
|
|
||||||
|
**Agent 1a: Product, stack, and local operation**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Read the target at <target>. Do not use network access. Return:
|
||||||
|
1. Product type, users, operators, and ordinary trust-sensitive actions.
|
||||||
|
2. Languages, frameworks, build system, runtimes, and locally visible deployment models.
|
||||||
|
3. Repository-relative entry points and subsystem boundaries.
|
||||||
|
4. Exact build and test commands that could run offline with local dependencies, their expected write locations, and the target-controlled inputs they process. Do not run them during reconnaissance.
|
||||||
|
5. Comparable software or protocol visible from local documentation and dependencies. If no useful comparison is source-grounded, say so.
|
||||||
|
6. Missing local toolchains or runtime facts that limit bounded execution.
|
||||||
|
Return only source facts with repository-relative file:line references.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent 1b: Principals, authority, and controls**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Read all source that establishes identity, authorization, isolation, and privilege. Map:
|
||||||
|
1. Each lower-trust principal and the actions it has by design.
|
||||||
|
2. Authentication or peer identity at each entry surface.
|
||||||
|
3. Per-resource authorization and tenant/owner scope.
|
||||||
|
4. Process, browser, workload, CI, plugin, model/tool, device, or local-IPC authority.
|
||||||
|
5. Privilege changes, confirmation, revocation, recovery, and fallback paths.
|
||||||
|
6. Which controls are source-visible and which depend on an unobserved deployment fact.
|
||||||
|
Return trust boundaries and control locations with repository-relative file:line references. Do not infer live reachability.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent 1c: Entry surfaces, copies, and sinks**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Inventory every source-visible place external or lower-trust input enters:
|
||||||
|
- HTTP/browser, RPC/message/protocol, files/archive/document, CLI/env/config, plugins/dependencies/CI, cloud events/IAM selectors, model context/tool arguments, mobile/deep-link/webview, and local IPC.
|
||||||
|
For each surface, follow major transformations, stored or derived copies, and security-relevant sinks. Record source-visible limits and parallel paths to the same effect.
|
||||||
|
Return repository-relative paths and line numbers. Be complete, but do not execute or send inputs.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent 1d: Local execution and deployment visibility**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Read tests, build definitions, manifests, packaging, and maintained environment overlays. Return:
|
||||||
|
1. Small offline tests or existing fixtures that could validate trust boundaries with dummy data inside the required OS-enforced sandbox.
|
||||||
|
2. Processes that could use an isolated loopback network namespace without external or shared dependencies.
|
||||||
|
3. Commands that would fetch dependencies, publish artifacts, contact paid/provider APIs, or affect shared state; mark them prohibited for this run.
|
||||||
|
4. Deployed controls and attachments that source cannot establish and therefore require needs_validation if decisive.
|
||||||
|
5. The final active source path for each deployment mode only where the repository selects it deterministically.
|
||||||
|
6. Whether the local platform can enforce an empty allowlisted environment, no external network, read-only target/toolchain mounts, scratch-only writes, and explicit CPU, memory, process, file-size, disk, and wall-clock limits. Missing controls block target-controlled execution.
|
||||||
|
7. Whether trusted parent-side code can promote predeclared scratch files with path-confined no-follow descriptor traversal, nonblocking regular-file checks, no-follow traversal of every destination parent, exclusive regular-file destination creation, and explicit per-file and cumulative size bounds. Missing promotion controls block use of scratch files as evidence.
|
||||||
|
```
|
||||||
|
|
||||||
|
Add focused reconnaissance agents for materially distinct deployment modes or subsystems that these four do not map. Do not silently omit them: if the budget gate in `SKILL.md` blocks a focused agent, launch nothing for it, seed the unmapped area as a `deferred` ledger unit with reason `budget_cannot_reserve_critics_and_validation`, and disclose the gap in the report.
|
||||||
|
|
||||||
|
## Prior-run input
|
||||||
|
|
||||||
|
Before selecting work, the parent reads every available prior `coverage-ledger.json` and `findings.json` for the same repo:
|
||||||
|
|
||||||
|
- Compare the source locations, controls, conditions, and source-derived identity for every prior record and unit against the current source.
|
||||||
|
- Carry an unchanged prior `confirmed` record into the current candidate set, with the same fingerprint, only when its relevant source, conditions, and qualifying evidence still apply. Link it to a current `planned` unit with `prior_status: "prior_confirmed_same_source"` and put only that root cause on the hunter exclusion list. The Phase 3 verifier that re-verifies the carried record becomes that unit's assignment owner; its source re-check is the unit's first check and moves the unit to `candidate` with the carried fingerprint.
|
||||||
|
- Build a current planned `prior_confirmed_changed_source` revalidation unit when any relevant source or condition changed. Do not exclude that root cause from hunting or assume the prior verdict still applies.
|
||||||
|
- Build current work units for every prior `needs_validation`, `deferred`, `blocked`, `out_of_scope`, and changed-source unit. These states are priority input, never deduplication or suppression keys.
|
||||||
|
- Carry a still-blocked prior `needs_validation` record with the same fingerprint only after current source supports its trace. Link it to a current `planned` unit with `prior_status: "prior_needs_validation"`; the record keeps the unresolved blocker. The Phase 3 verifier that re-checks the carried record becomes that unit's assignment owner; its re-check is the unit's first check and moves the unit to `candidate` with the carried fingerprint. Include the record in final verification.
|
||||||
|
- Treat prior rejected records as stale claims unless current evidence changes the failed trace or missing condition. An unchanged rejection suppresses only that exact claim, not review of the coverage unit.
|
||||||
|
- Record missing or incompatible ledgers instead of treating them as empty coverage.
|
||||||
|
|
||||||
|
State paths and source refs used in `run-metadata.json`. Summarize only the coverage consequences in `architecture.md`.
|
||||||
|
|
||||||
|
## Architecture summary and companion selection
|
||||||
|
|
||||||
|
The parent synthesizes `<output-dir>/architecture.md`, with a hard cap of about 1,000 words. Include:
|
||||||
|
|
||||||
|
1. Product, principals, normal authority, and protected resources.
|
||||||
|
2. The comparable-software baseline from Agent 1a, when one is source-grounded: what security trade-offs the comparable accepts. Use it to calibrate effort and severity, never to dismiss a demonstrated finding; if the comparable shares a defect pattern that has mattered in practice, that strengthens the finding. Omit this line when no meaningful comparable exists.
|
||||||
|
3. Tech stack, source-visible deployment paths, and offline build/test limits.
|
||||||
|
4. Entry surfaces and the important source-to-sink or lifecycle paths.
|
||||||
|
5. Trust boundaries and the strongest source-visible control on each.
|
||||||
|
6. Repository-relative starting paths.
|
||||||
|
7. Prior coverage gaps, changed-source and blocked revalidation targets, and same-source confirmed exclusions.
|
||||||
|
8. A short companion-selection summary derived from [ATTACK-CLASSES.md](ATTACK-CLASSES.md): selected files and the source-visible boundaries that require them.
|
||||||
|
|
||||||
|
Keep the assignment-level ordinary block, selected companion blocks, and excluded blocks with reasons in each ledger unit, not in `architecture.md`. This keeps the architecture cap valid for large runs and makes the exact hunter prompt map machine-checkable.
|
||||||
|
|
||||||
|
Do not select a companion file merely because the language or dependency name appears. Select it because reconnaissance found the trust-sensitive boundary described by its `When to use this file` section. Do not exclude a visible boundary just because another agent will review a related class.
|
||||||
|
|
||||||
|
## Deterministic coverage ledger
|
||||||
|
|
||||||
|
The parent writes `<output-dir>/coverage-ledger.json` as a top-level JSON array. Derive one unit for every material combination of entry surface, trust boundary, subsystem, and applicable ordinary or companion attack class at the granularity the run profile sets (`quick` uses one all-in-scope subsystem identity; `deep` adds lifecycle modes). For a scoped run, seed in-scope surfaces for assignment and retain discovered excluded surfaces as `out_of_scope` units so later full runs can turn them into current work.
|
||||||
|
|
||||||
|
Each dimension has a human label and a stable source-derived value in `canonical_refs`. Use the same canonical reference for the same source object across runs even if its display label changes. Suitable references include a repository-relative entry path plus exported scope, a route or message identity defined in source, the source control that defines a boundary, a repository package path, and the exact attack-class block reference. A block reference is `FILE.md#` plus the exact class name as written in bold or as a heading in that file — a stable identifier matched against the file text, not a rendered HTML anchor. For companion section blocks, use the heading text before any parenthetical qualifier (for example `Core discipline`). Do not derive references by lowercasing or slugging display labels.
|
||||||
|
|
||||||
|
Derive `coverage_id` without lossy slugs:
|
||||||
|
|
||||||
|
1. Require every reference to be Unicode NFC with valid scalar values, visible content, no control, format, line/paragraph separator, or default-ignorable code point, and no surrounding whitespace.
|
||||||
|
2. Encode its UTF-8 bytes with RFC 3986 percent encoding: leave only `A-Z a-z 0-9 - . _ ~` unescaped and use uppercase `%HH` for every other byte.
|
||||||
|
3. Join encoded `surface`, `boundary`, `subsystem`, and `attack_class` references with `::`; append encoded `lifecycle` when present.
|
||||||
|
|
||||||
|
Use the fixed canonical value `profile/quick/all-in-scope-subsystems` for the quick profile's coarsened subsystem dimension. Do not include wave number, agent, verdict, severity, or line number in a reference or ID. Sort units lexicographically by `coverage_id` before each assignment. Fail on every duplicate ID. If duplicate IDs have different semantic fields, treat that as a canonical identity collision; never merge or silently overwrite them. The validator also rejects one semantic tuple represented by different canonical references.
|
||||||
|
|
||||||
|
Each unit records:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"coverage_id": "...",
|
||||||
|
"canonical_refs": {
|
||||||
|
"surface": "src/router.ts#POST /users/:id",
|
||||||
|
"boundary": "src/authz.ts#requireOwner",
|
||||||
|
"subsystem": "packages/api",
|
||||||
|
"attack_class": "ATTACK-CLASSES.md#Access control"
|
||||||
|
},
|
||||||
|
"surface": "...",
|
||||||
|
"boundary": "...",
|
||||||
|
"subsystem": "...",
|
||||||
|
"attack_class": "...",
|
||||||
|
"starting_paths": ["repo/relative/path"],
|
||||||
|
"ordinary_attack_class_block": "ATTACK-CLASSES.md#Access control",
|
||||||
|
"selected_companion_blocks": ["FILE.md#section"],
|
||||||
|
"excluded_blocks": [{"block": "FILE.md#section", "reason": "..."}],
|
||||||
|
"prior_status": "new|prior_confirmed_same_source|prior_confirmed_changed_source|prior_needs_validation|prior_deferred|prior_blocked|prior_out_of_scope|prior_covered_same_source|prior_covered_changed_source|prior_rejected_claim_changed|none",
|
||||||
|
"attempts": [],
|
||||||
|
"wave": 1,
|
||||||
|
"status": "planned",
|
||||||
|
"agent_id": null,
|
||||||
|
"reviewed_paths": [],
|
||||||
|
"local_checks": [],
|
||||||
|
"result_fingerprints": [],
|
||||||
|
"unresolved": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `lifecycle` is material, add both `canonical_refs.lifecycle` and a human `lifecycle` field. `ordinary_attack_class_block` is null only when no ordinary block applies. The selected companion list includes each applicable class plus its companion `Core discipline`, `Universal moves`, and `Validation rules`; `excluded_blocks` records every considered but unselected block and the source fact that excludes it.
|
||||||
|
|
||||||
|
The parent may add bookkeeping fields but keeps the semantic fields above stable. In `prior_status`, `new` marks a surface first seen in this run when compatible prior ledgers exist; `none` marks a unit seeded when no compatible prior ledger is available. Prior `deferred`, `blocked`, `out_of_scope`, and changed-source units initialize as current `planned` work when now in scope. A prior same-source covered unit remains visible in the current ledger; assign changed source, important lifecycle paths, and exact conflicts first, then use the coverage critic to decide whether it needs another pass.
|
||||||
|
|
||||||
|
`attempts` is an append-only archive for evidence-bearing assignments that a coverage critic reopens. Before reassignment, append the prior unit's exact `wave`, `status`, `agent_id`, `reviewed_paths`, `local_checks`, `result_fingerprints`, and `unresolved`, plus the critic's source-backed `reassignment_reason`. Only `blocked`, `covered`, and `candidate` states can be archived. Archived attempts retain the same state and evidence invariants as live units, use strictly increasing waves below the current wave, and retain their producing owners and artifacts. The next assignment increments `wave`, uses a fresh owner, and starts with empty live evidence. If the profile or budget prevents another assignment, increment `wave` and use live `deferred` state with null owner, empty evidence, and the stop reason. Never copy an archived owner's checks or artifacts into the live state. A later live terminal state contains only the new attempt's evidence; the archive remains unchanged.
|
||||||
|
|
||||||
|
Enforce this state table exactly:
|
||||||
|
|
||||||
|
| Status | Unit `agent_id` | `reviewed_paths` / `local_checks` | `result_fingerprints` | `unresolved` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `planned` | null | empty | empty | empty |
|
||||||
|
| `not_applicable`, `out_of_scope`, `deferred` | null | empty | empty | nonempty reason |
|
||||||
|
| `in_progress` | canonical owner | empty | empty | empty |
|
||||||
|
| `blocked` | canonical owner | both nonempty owned partial evidence | empty | nonempty blocker |
|
||||||
|
| `covered` | canonical owner | both nonempty | empty | empty |
|
||||||
|
| `candidate` | canonical owner | both nonempty | nonempty | optional |
|
||||||
|
|
||||||
|
Canonical agent IDs match `^[a-z0-9][a-z0-9_-]{0,63}$` and are not Windows device names. Lowercase is mandatory, so one ledger cannot contain case-fold aliases. The unit `agent_id` records the assignment owner. Every check records its own `agent_id` and nonempty `reviewed_paths`; the unit-level `reviewed_paths` is exactly their union. A source-only check uses `artifact: null`. A local check requires a regular file promoted only by trusted parent-side code under exactly `agents/<check.agent_id>/artifacts/`. This lets hunter and verifier checks coexist in one unit. Scratch paths, output-root files, symlinks, special files, and another check owner's artifacts are not evidence.
|
||||||
|
|
||||||
|
The ledger is the coverage claim. An architecture summary, agent count, or generic "auth reviewed" sentence is not coverage evidence. Phase 2 closes units only from the paths and checks in a hunter's structured result.
|
||||||
|
|
||||||
|
Run `node <skill-dir>/validate-coverage-ledger.cjs <output-dir>/coverage-ledger.json` after seeding, after every parent update, and before Phase 6. The validator rejects input beyond 5 MiB, 64 nesting levels, 10,000 units, 1,000 entries in a nested collection, or 500,000 traversed values, and caps reported validation errors at 100. In practice the 5 MiB byte limit holds roughly 2,000-5,000 realistic units, so it binds before the 10,000-unit cap. Fix every error before assigning work or making a coverage claim.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Resource Exhaustion and Availability Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when untrusted requests, messages, files, tenant state, or agent work can consume CPU, memory, disk, connections, worker slots, paid APIs, or queue capacity, or can deadlock/crash a shared service. This domain distinguishes a source-reviewable availability vulnerability from a general performance issue. Never validate by stressing a shared or live service.
|
||||||
|
|
||||||
|
Use `MEMORY-SAFETY-AND-BINARY.md` for memory-integrity defects and `PROTOCOLS-RPC-AND-MESSAGING.md` for broker delivery logic. A reachable fatal error belongs here for shared impact even when the underlying parser is covered elsewhere.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- Require an input-to-cost path, a missing effective bound, and impact on another user, shared service, safety function, or operator-owned spend. Self-limiting work in the requester's own process is not a service vulnerability.
|
||||||
|
- A missing rate limit is not enough. Check body/message/file caps, concurrency, queues, deadlines, database constraints, upstream gateways, and per-tenant quotas before calling a path unbounded.
|
||||||
|
- Do not run stress, saturation, or production tests. Use asymptotic analysis, small boundary fixtures, mocked paid calls, strict local resource limits, and deterministic cancellation tests.
|
||||||
|
- State attacker cost, service work, persistence, scope, and recovery. One bounded input with superlinear or persistent shared effect is materially different from sustained volume.
|
||||||
|
- Use `confirmed` for source-visible bounds failures demonstrated safely. Use `needs_validation` when upstream caps, deployed topology, autoscaling, paid quota, or recovery behavior is outside the repository.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Computational amplification attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Superlinear parsing, matching, or evaluation**
|
||||||
|
Small accepted input drives catastrophic regex backtracking, nested parsing, recursive validation, symbolic evaluation, graph traversal, template expansion, or adversarial sort/hash behavior. Derive accepted depth/cardinality and complexity, then demonstrate a bounded growth curve locally.
|
||||||
|
|
||||||
|
**Decompression and representation amplification**
|
||||||
|
Compressed, sparse, nested, aliased, or encoded input expands far beyond the checked transfer or file size. Verify limits after every expansion and across parser stages, including archives, images, fonts, structured documents, and protocol compression tables.
|
||||||
|
|
||||||
|
**Database and downstream query amplification**
|
||||||
|
A small request creates broad scans, pathological joins, fan-out, unbounded sort/aggregation, or many downstream calls because query depth, filter cardinality, pagination, or expansion fields are not bounded. Confirm authorization does not intentionally permit the same resource scope.
|
||||||
|
|
||||||
|
## Resource accumulation attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Unbounded buffering and cardinality**
|
||||||
|
Bodies, out-of-order streams, uploads, sessions, unique cache keys, metrics labels, log fields, subscriptions, or pending jobs accumulate without per-item and aggregate limits. Find cleanup and expiration on disconnect, timeout, cancellation, and partial parse.
|
||||||
|
|
||||||
|
**File descriptor, handle, and temporary-resource leaks**
|
||||||
|
Malformed or canceled work misses cleanup and retains sockets, files, database cursors, timers, subprocesses, temporary files, or object references. Confirm the leak repeats through bounded local iterations and affects a shared pool.
|
||||||
|
|
||||||
|
**Detached work after cancellation**
|
||||||
|
Client timeout, disconnect, canceled job, or failed authorization returns control but leaves database, model, network, or worker work running. Trace cancellation and deadline propagation through every layer.
|
||||||
|
|
||||||
|
## Quota and scheduling attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Pre-authentication work imbalance**
|
||||||
|
Expensive parsing, key lookup, cryptography, decompression, or external requests happen before authentication and the earliest size/rate gate. Compare minimal requester effort to shared service cost and check upstream limits.
|
||||||
|
|
||||||
|
**Quota-accounting scope and reset gaps**
|
||||||
|
Accounting uses attacker-influenceable IP, route, tenant, key prefix, task ID, or other dimension, allowing one principal's work to escape its intended budget or consume another principal's allocation. Review integer overflow, distributed races, retries, reconnects, and account switching.
|
||||||
|
|
||||||
|
**Worker, pool, and priority starvation**
|
||||||
|
Low-priority or attacker-controlled jobs hold shared locks, workers, database pools, event-loop turns, or scheduler priority needed by unrelated users. Require a path that bypasses queue/concurrency fairness or retains a slot beyond its deadline.
|
||||||
|
|
||||||
|
## Failure and recovery attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Reachable fatal error or deadlock**
|
||||||
|
An untrusted input reaches `panic`, abort, fatal assertion, unhandled exception, process exit, lock cycle, or infinite loop in a shared process. Confirm supervisor scope and whether one worker or the whole service becomes unavailable. A restarted isolated worker may reduce impact but does not erase the defect.
|
||||||
|
|
||||||
|
**Retry storm and fail-open amplification**
|
||||||
|
Timeouts, dependency errors, partially processed messages, or health-check failures trigger synchronized or unbounded retries without jitter, ceilings, circuit breaking, or deduplication. Verify one bounded failure source can create persistent aggregate work.
|
||||||
|
|
||||||
|
**Poison-record and head-of-line blocking**
|
||||||
|
One malformed record or message repeatedly fails at the front of a shared queue, partition, startup scan, migration, or recovery loop. Review skip/quarantine policy, offsets, and whether other tenants share the blocked unit.
|
||||||
|
|
||||||
|
**Unsafe recovery and capacity rollback**
|
||||||
|
A restart, restore, fallback, or cleanup path rebuilds unbounded state, ignores current quotas, or restores the input that immediately repeats failure. Recovery correctness is part of availability.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Build an input-to-resource table: earliest accepted size/cardinality, work before auth, downstream fan-out, persistence, shared pool, limit and cleanup owner, recovery.
|
||||||
|
- Compare aggregate limits with per-object limits. Ten thousand valid one-byte items may evade a per-message cap while exhausting tenant-wide or process-wide state.
|
||||||
|
- Validate only in an isolated fixture with strict CPU/memory/time limits and small growth points. Mock external and paid calls and stop once the missing bound or cancellation is observable.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Name untrusted input, requester work, service amplification or retained resource, shared blast radius, and recovery. Missing limits without concrete shared impact are hardening.
|
||||||
|
2. Confirm no source-visible upstream, parser, queue, tenant, or framework bound prevents the path. Unknown deployed controls require `needs_validation`.
|
||||||
|
3. For superlinear behavior, establish the accepted complexity and bounded local growth. For leaks, show repeatable retention after cleanup should occur. For fatal paths, identify process/supervisor isolation.
|
||||||
|
4. Prioritize by low requester work, unauthenticated reachability, cross-tenant scope, persistence, and poor recovery; do not validate with availability impact.
|
||||||
|
5. Return `confirmed` only with safe local proof and meaningful shared effect. Return `needs_validation` with the exact upstream limit, topology, quota, or recovery observation an owner must check.
|
||||||
192
.agents/skills/security-audit/SKILL.md
Normal file
192
.agents/skills/security-audit/SKILL.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
---
|
||||||
|
name: security-audit
|
||||||
|
description: Security guidance and vulnerability review for codebases, APIs, services, CLI tools, libraries, and daemons. Use for security questions, focused reviews, vulnerability research, security audits, or pen tests. Run the complete workflow only for explicit codebase audit or pen-test requests, full/comprehensive/end-to-end reviews, or requested report artifacts.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security Audit
|
||||||
|
|
||||||
|
Find vulnerabilities that violate a real trust boundary, then give owners the source evidence, safe reproduction, priority, and smallest effective fix. This is a defensive, source-first workflow. A candidate without a concrete affected principal, resource, or security outcome is not a confirmed finding.
|
||||||
|
|
||||||
|
## Operating modes
|
||||||
|
|
||||||
|
This skill is guidance by default. Loading it does not authorize the complete audit workflow or file creation.
|
||||||
|
|
||||||
|
- **Guidance mode**: For security questions, focused reviews, methodology, triage, or investigation of specific findings, use only the relevant parts of this skill. Do not automatically run all six phases, create an output directory, or write audit artifacts. You may launch focused agents when useful; they return results to the current task.
|
||||||
|
- **Full audit mode**: Use the complete workflow when the user explicitly asks to audit or pen-test a codebase, asks for a full, comprehensive, or end-to-end security review, or requests report artifacts. Run all six phases and write the files defined below.
|
||||||
|
|
||||||
|
If the request could mean either mode, ask one focused question before creating files or starting the complete workflow.
|
||||||
|
|
||||||
|
## Platform terminology
|
||||||
|
|
||||||
|
This skill is agent-neutral:
|
||||||
|
|
||||||
|
- **Parent** is the agent that coordinates the run and owns shared state.
|
||||||
|
- **Task tool** is the platform's delegation or sub-agent mechanism.
|
||||||
|
- **`research` agent** is a delegated agent for focused source exploration and factual verification.
|
||||||
|
- **`general` agent** is a delegated agent for broad investigation and bounded local execution.
|
||||||
|
- **`subagent_type:`** in a heading names which of these two delegated agent roles runs that work.
|
||||||
|
|
||||||
|
Use equivalent platform capabilities while preserving role, write-isolation, prompt, and independence boundaries.
|
||||||
|
|
||||||
|
## Universal execution safety
|
||||||
|
|
||||||
|
These rules apply in both operating modes. Source inspection is read-only. Run target-controlled builds, tests, processes, browsers, emulators, fuzzers, and fixture processing only inside an OS-enforced sandbox that provides all of these controls:
|
||||||
|
|
||||||
|
- no external network; use only an isolated loopback namespace when the check needs local client/server traffic;
|
||||||
|
- an empty environment populated from an explicit allowlist with safe values, with scratch-local `HOME`, temporary directories, and caches;
|
||||||
|
- a read-only target and toolchain, with the target-controlled process able to write only inside its assigned `scratch/` directory; and
|
||||||
|
- explicit low CPU, memory, process, file-size, disk, and wall-clock limits.
|
||||||
|
|
||||||
|
The agent, outside the target-controlled process, may make a disposable source copy in an assigned `scratch/` directory when a build must write beside source. In guidance mode, do not retain target-controlled files. In full audit mode, only trusted parent-side code may promote the minimum non-secret result to retained `artifacts/` using the procedure under Write isolation. Never expose a retained output directory (other than the agent's own assigned `scratch/`), another agent's directory, the host home directory, credentials, sockets, or shared services to target code. Do not install dependencies or let builds fetch them. Use only tools and dependencies already available locally. If every control cannot be enforced, do not execute target code: report the missing sandbox capability as a needs-validation blocker and give a safe validation plan.
|
||||||
|
|
||||||
|
Use dummy principals, fixtures, and secrets. Do not probe deployed endpoints, external services, shared infrastructure, production identities, other users' data, or live control planes. Do not test availability against a live or shared process, publish artifacts, alter releases, spend paid API quota, or continue beyond the minimum local effect needed to establish a defect. If the decisive fact is outside source or the sandboxed fixture, report it as needing validation.
|
||||||
|
|
||||||
|
## Full audit setup
|
||||||
|
|
||||||
|
In full audit mode, resolve these values before reconnaissance:
|
||||||
|
|
||||||
|
- **Skill directory**: the absolute directory containing this `SKILL.md`.
|
||||||
|
- **Target**: the absolute repository root under review.
|
||||||
|
- **Repo name**: a stable repository identifier from the directory or local Git remote.
|
||||||
|
- **Output directory**: a new writable directory outside the target, defaulting to `~/security-audit-skill/<repo-name>/run-<N>`, where `<N>` is the next unused integer. Use a directory inside the target only when the user explicitly selects it and the parent verifies that version control ignores the whole directory. Otherwise stop and request an external path.
|
||||||
|
- **Source ref**: the reviewed commit and whether the worktree is dirty. Do not treat unreviewed generated or modified files as another revision.
|
||||||
|
|
||||||
|
### Write isolation
|
||||||
|
|
||||||
|
The parent creates and is the only writer of shared run files:
|
||||||
|
|
||||||
|
- `run-metadata.json`
|
||||||
|
- `architecture.md`
|
||||||
|
- `coverage-ledger.json`
|
||||||
|
- `findings.json`
|
||||||
|
- `REPORT.md`
|
||||||
|
- `FINDINGS-DETAIL.md`
|
||||||
|
- `NEEDS-VALIDATION.md`
|
||||||
|
|
||||||
|
Each hunter or verifier receives a unique root under `<output-dir>/agents/<agent-id>/`, with separate `scratch/` and `artifacts/` directories. Canonical agent IDs match `^[a-z0-9][a-z0-9_-]{0,63}$` and must not equal a Windows device name such as `con`, `prn`, `aux`, `nul`, `com1` through `com9`, or `lpt1` through `lpt9`. Lowercase IDs prevent case-fold collisions. The agent and every target-controlled process may write only to `scratch/`; retained `artifacts/` is parent-owned, is never exposed to the sandbox, and is writable only by trusted parent-side promotion code. Agents may not change shared files, target source, retained artifacts, or another agent's directory. Do not use `/tmp` or the host home directory as a writable fallback.
|
||||||
|
|
||||||
|
Before execution, the parent opens and retains trusted, non-inheritable directory descriptors for the agent's `scratch/` and `artifacts/` roots, and records an allowlist of expected scratch-relative artifact files plus explicit per-file and cumulative byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox and all its processes terminate, trusted parent-side code promotes each allowlisted file separately:
|
||||||
|
|
||||||
|
1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or symlinked components.
|
||||||
|
2. Walk each parent component from the retained scratch-root descriptor with no-follow directory-relative operations; never reopen by path.
|
||||||
|
3. Open the leaf no-follow and nonblocking.
|
||||||
|
4. Verify with `fstat` that it is a regular file with link count exactly one and within the recorded per-file and cumulative byte limits.
|
||||||
|
5. Enforce those limits again while reading from that descriptor.
|
||||||
|
6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity, type, link count, or size.
|
||||||
|
7. For the destination, walk every parent component from the retained artifacts-root descriptor with no-follow directory-relative operations; require each existing component to be a real directory, and create any missing directory exclusively before reopening and verifying it no-follow.
|
||||||
|
8. Create the leaf exclusively without following links, verify that the opened destination is a regular file with link count exactly one, and copy from the verified source descriptor without reopening either path.
|
||||||
|
9. Use equivalent race-safe APIs on non-POSIX systems.
|
||||||
|
10. Never recursively copy or glob scratch, extract an archive into artifacts, or open or promote a symlink, FIFO, socket, device, directory, hard-linked file, changing file, or file that exceeds its bound.
|
||||||
|
11. If any check is unavailable, cannot be enforced, or fails, discard the scratch entry; if it is decisive evidence, retain `needs_validation` with the exact promotion blocker.
|
||||||
|
|
||||||
|
[HUNTING.md](HUNTING.md) and [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md) carry this procedure as one identical fenced block for hunter and verifier prompts; it states the same rules in the same order as this list.
|
||||||
|
|
||||||
|
For a reproduced check, record the command, exact test input, sandbox limits, and only the allowlisted environment variable names plus safe non-secret values needed to reproduce it. Never capture or copy the ambient environment, inherited variables, credential values, authentication state, or unrelated host paths. Launch from an empty environment rather than trying to redact one after execution.
|
||||||
|
|
||||||
|
Before delegation, the parent writes `run-metadata.json` with at least `run_id`, `repo`, `target`, `source_ref`, `profile`, `scope_paths`, `budget` (null if unset), `execution_policy: "sandboxed-source-and-local-only"`, selected companion files, prior-run paths, shared-file owners, and `run_status: "in_progress"`. Update metadata only when those facts change; candidate state belongs in the coverage ledger and `findings.json`.
|
||||||
|
|
||||||
|
## Full audit planning
|
||||||
|
|
||||||
|
The coverage, prior-run, profile, and budget requirements in this section apply only in full audit mode.
|
||||||
|
|
||||||
|
### Coverage and prior runs
|
||||||
|
|
||||||
|
No one pass is complete. Build a deterministic coverage plan before hunting and update it after every agent result. [RECONNAISSANCE.md](RECONNAISSANCE.md) defines the stable coverage units and [HUNTING.md](HUNTING.md) defines coverage-critic waves. The parent alone updates the ledger.
|
||||||
|
|
||||||
|
If prior runs exist, read every compatible `coverage-ledger.json` and `findings.json` before planning the current run:
|
||||||
|
|
||||||
|
1. Compare the relevant current source with each prior record and unit. A prior source ref alone is not evidence that a path is unchanged.
|
||||||
|
2. Carry a prior `confirmed` record into the current candidate set only when its relevant source and conditions are unchanged and its evidence still meets the current contract. Link it to a current ledger unit seeded `planned`, preserve its fingerprint, exclude only that carried root cause from hunters, and send the carried record through the current final verification path; the Phase 3 verifier that re-checks it becomes that unit's assignment owner and moves it to `candidate`.
|
||||||
|
3. When relevant source for a prior `confirmed` record changed, create a current planned revalidation unit. Do not put that record on the hunter exclusion list. It remains confirmed only if current independent validation establishes the current path and result.
|
||||||
|
4. Make prior `needs_validation`, `deferred`, `blocked`, `out_of_scope`, and any changed-source unit current work. A still-external `needs_validation` record may be carried only after the current source trace is checked and linked by fingerprint to a current `planned` unit whose verifier re-check supplies its owner and evidence; the record keeps the unresolved blocker. These prior states never suppress a current unit.
|
||||||
|
5. A prior same-source covered unit may inform priority, but it remains visible in the current ledger. A prior `rejected` record suppresses only the unchanged failed claim, not coverage of its unit; changed evidence creates current work.
|
||||||
|
6. Read the prior profile and scope. A prior `quick` or scoped ledger contributes only its recorded evidence and gaps, never an implied "rest is fine."
|
||||||
|
|
||||||
|
If no prior ledger exists, say so in the final coverage statement. Never imply that one run exhausts the target.
|
||||||
|
|
||||||
|
### Run profiles and scope
|
||||||
|
|
||||||
|
During full audit setup, pick a profile from the user's request or propose one from the target's size and stakes. Record it in `run-metadata.json` (`profile`, `scope_paths`) and state it in the report. The default is `standard`.
|
||||||
|
|
||||||
|
- **`quick`** — a bounded pass for small targets, re-runs, or a fast first look. Coarsen ledger units to surface × boundary × attack class (subsystem uses the fixed canonical `profile/quick/all-in-scope-subsystems` identifier), run exactly one hunter wave followed by exactly one final coverage-critic pass, and use one fresh verifier per candidate for both candidate validation and final record verification. Do not launch a follow-up hunter wave: record the critic's accepted discoveries and reassignments as `deferred`.
|
||||||
|
- **`standard`** — the workflow as written.
|
||||||
|
- **`deep`** — for high-stakes or large targets. Split ledger units per subsystem and lifecycle mode, run critic waves to a clean pass, keep candidate validation and final record verification as separate fresh agents, and give `prior_covered_same_source` units an independent second pass.
|
||||||
|
|
||||||
|
A **scoped run** audits a subset: named paths, one subsystem, one companion domain, or the diff between two source refs. Seed ledger units only for in-scope surfaces and record everything else as `out_of_scope` — never as `covered`. A scoped or `quick` run must present itself as partial coverage.
|
||||||
|
|
||||||
|
Profiles change breadth and redundancy, never the evidence bar. Do not scale away the candidate gate, the source/local execution boundary, `needs_validation` discipline, schema validation, or independent verification of `confirmed` records.
|
||||||
|
|
||||||
|
#### Cost budget
|
||||||
|
|
||||||
|
The ledger makes spend countable: one unit is roughly one hunter assignment, and one surviving candidate is one or two verifier assignments depending on profile. When the user sets a budget — or the parent proposes one for a large target — record `budget` in `run-metadata.json` as a maximum number of agent invocations across all phases.
|
||||||
|
|
||||||
|
Apply the strict budget gate before launching any reconnaissance agent. Reserve the four baseline reconnaissance calls, one final post-wave critic for `quick` or one post-wave plus one distinct final-clean critic for `standard`/`deep`, and at least one verifier call. Add focused reconnaissance only after repeating this gate for each extra call. If the requested budget cannot fund that minimum, launch no agent: ask for a larger budget, narrower scope, or different profile. If the request remains unchanged, set `run_status: "incomplete"` with `incomplete_reason: "budget_cannot_fund_reconnaissance_and_reserves"` and report that no audit pass ran.
|
||||||
|
|
||||||
|
Spend it in this order:
|
||||||
|
|
||||||
|
1. Count reconnaissance, every post-wave critic, and the separate final-clean critic as agent invocations.
|
||||||
|
2. **Reserve critics and validation before hunting.** For `quick`, reserve its one post-wave final critic. Before every `standard` or `deep` hunter wave, reserve one immediate post-wave critic plus one distinct final-clean critic. Also reserve verifier cost from the profile (about 1 or 2 agents per expected candidate; when in doubt reserve 30% of the balance after critic reservation). Never assign hunters into either reserve.
|
||||||
|
3. Assign hunters to units in priority order until the hunting allowance is spent. Spend the reserved post-wave critic immediately after that wave; keep the final-clean and validation reserves intact.
|
||||||
|
4. Before a later wave, reserve its new post-wave critic again. If the remaining budget cannot cover the required critic calls and validation reserve, launch no hunters from that wave, mark its planned units `deferred` with reason `budget_cannot_reserve_critics_and_validation`, and use the retained final-clean critic to record the resulting gap.
|
||||||
|
|
||||||
|
Before wave 1, update the pre-recon estimate with seeded units, implied hunter count, mandatory critic calls, validation reserve, and whether the remaining budget covers the plan. If it clearly cannot, say so and propose either a tighter scope or a coarser profile instead of silently thinning evidence. If later facts consume the required final-critic reserve, launch no hunters, mark all planned work deferred, set the run incomplete with reason `critic_budget_exhausted`, and make no complete-coverage claim.
|
||||||
|
|
||||||
|
A strict total-agent budget can still be exceeded by an unexpectedly large candidate set or by a material Phase 5 replacement that needs another independent verifier. If the remaining budget cannot validate every candidate, stop hunting, validate candidates in fingerprint order while the budget permits, and set `run_status: "incomplete"` plus `incomplete_reason: "validation_budget_exhausted"`. Keep each unvalidated fingerprint linked to a `candidate` ledger unit with that unresolved reason. Do not put an unvalidated candidate in `findings.json`, relabel it `needs_validation`, or report the run as complete. Phase 6 may produce a partial report only if its first section states that candidate validation is incomplete and lists the affected fingerprints and units. Never exceed a user-set strict budget silently.
|
||||||
|
|
||||||
|
## Core principles
|
||||||
|
|
||||||
|
### Require a boundary and result
|
||||||
|
|
||||||
|
For every candidate, name the lower-trust principal, accepted input or action, intended control, crossed boundary, affected principal or resource, and concrete observed or owner-observable result. Do not elevate a missing best practice, guessed deployment behavior, generic parser crash, or self-impact into a security finding.
|
||||||
|
|
||||||
|
### Use bounded local evidence
|
||||||
|
|
||||||
|
Static analysis establishes the source path. Sandboxed local tests resolve behavior when all execution controls are available: a minimal function harness, existing unit test, small parser fixture, dummy-tenant integration test, locally rendered configuration, or bounded isolated-loopback client. Stop at a wrong return value, unauthorized dummy record, sanitizer finding, policy difference, or other minimum effect. Do not extend the local check beyond the minimum boundary result or produce persistence, post-fault, or concealment material.
|
||||||
|
|
||||||
|
### Respect source visibility
|
||||||
|
|
||||||
|
Deployment controls, proxy behavior, provider settings, browser headers, identity policy, broker ACLs, packaging, and topology are real controls. If they are required and absent from the repository, do not assume either presence or absence. Use `needs_validation` with the exact missing fact and a safe owner-observed or local plan.
|
||||||
|
|
||||||
|
### Separate priority from certainty
|
||||||
|
|
||||||
|
Only `confirmed` records receive severity. Likelihood and impact must reflect the demonstrated conditions and result; overall severity cannot exceed demonstrated impact. `needs_validation` means a specific source-grounded boundary hypothesis is blocked, not a low-confidence confirmed vulnerability, and it has no severity.
|
||||||
|
|
||||||
|
Calibrate overall severity with these anchors:
|
||||||
|
|
||||||
|
- **critical** — an unauthenticated actor gains code execution, full data-store access, or takeover of arbitrary accounts.
|
||||||
|
- **high** — an actor fully defeats an explicit security control with real consequences: authentication bypass, cross-tenant read or write, stored script execution affecting other users, authenticated code execution, or an unauthenticated remote stop of a shared service.
|
||||||
|
- **medium** — a real boundary violation with limited blast radius, uncommon preconditions, or consequences confined to a narrow resource set.
|
||||||
|
- **low** — disclosure of non-secret internals, or an effect requiring sustained effort for minimal gain.
|
||||||
|
- **informational** — a confirmed but minimal-impact observation, useful mainly as a prerequisite inside a larger finding.
|
||||||
|
|
||||||
|
The high/medium discriminator: does the demonstrated result fully defeat an explicit control for an action with real consequences, or only weaken it? If you cannot state the concrete damage, the severity is lower than it feels.
|
||||||
|
|
||||||
|
### Recommend the smallest effective source fix
|
||||||
|
|
||||||
|
For each confirmed finding, identify the invariant the code must enforce and the narrowest source change that enforces it at the last trusted decision point. Prefer specific repository-relative changes and regression tests over generic hardening advice. The audit describes fixes; it does not modify target source.
|
||||||
|
|
||||||
|
## Full audit workflow
|
||||||
|
|
||||||
|
In full audit mode, follow all six phases in order:
|
||||||
|
|
||||||
|
1. **Reconnaissance** — map the source, trust boundaries, local build paths, companion selections, prior evidence, and initial deterministic coverage ledger with [RECONNAISSANCE.md](RECONNAISSANCE.md).
|
||||||
|
2. **Coverage-led hunting waves** — assign isolated hunters from the ledger and collect structured candidate results with [HUNTING.md](HUNTING.md), [ATTACK-CLASSES.md](ATTACK-CLASSES.md), and the selected domain companions.
|
||||||
|
3. **Candidate validation** — consolidate fingerprints and give every candidate to a fresh source verifier as defined in [VALIDATION-AND-REPORTING.md](VALIDATION-AND-REPORTING.md).
|
||||||
|
4. **Structured output** — write all final `confirmed`, `needs_validation`, and `rejected` records to `findings.json`; validate it with `report-schema.json` and `validate-findings.cjs`, and validate the coverage claim with `validate-coverage-ledger.cjs`.
|
||||||
|
5. **Independent record verification** — use fresh agents to verify final source claims and reconcile corrections or state changes.
|
||||||
|
6. **Target-neutral report** — derive `REPORT.md`, `FINDINGS-DETAIL.md`, and `NEEDS-VALIDATION.md` from the final records, with no live-probe instructions.
|
||||||
|
|
||||||
|
Do not end the run before one of exactly two terminal states: (a) all Phase 6 artifacts are written and both validators pass, or (b) `run_status: "incomplete"` is recorded with its exact reason and the gap is disclosed in the report. Never stop mid-phase.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
1. Checklist deviations presented as vulnerabilities.
|
||||||
|
2. Defense-in-depth advice with no reachable boundary violation.
|
||||||
|
3. Live or shared-environment testing where bounded local evidence is insufficient.
|
||||||
|
4. Guessing provider, proxy, browser, identity, or deployment behavior not present in source.
|
||||||
|
5. Treating intended same-principal authority or self-impact as a cross-boundary result.
|
||||||
|
6. Reporting a parser or runtime effect stronger than the observed effect.
|
||||||
|
7. Emitting prose-only hunter results that cannot be deduplicated or verified.
|
||||||
|
8. Re-reporting carried same-source prior confirmed records or using them as exemplars that anchor the hunt.
|
||||||
|
9. Assigning severity to `needs_validation` records.
|
||||||
|
10. Writing the report before independent verification or letting prose and JSON disagree.
|
||||||
73
.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md
Normal file
73
.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Supply Chain and Release Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when the target resolves dependencies, builds from untrusted contributions, runs CI, creates release artifacts, signs or promotes builds, loads plugins, or updates deployed software. This domain covers trust handoffs from source and dependency to the artifact a user runs. Use `MEMORY-SAFETY-AND-BINARY.md` for flaws inside a local binary loader and `CLOUD-AND-DEPLOYMENT.md` for runtime workload authority.
|
||||||
|
|
||||||
|
Split large targets into dependency resolution, CI isolation, artifact provenance, release authorization, and updater/plugin trust.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- A mutable or known-vulnerable dependency is not a finding by itself. Show who can influence resolution, which build consumes it, and what execution or release boundary follows.
|
||||||
|
- Follow integrity across every handoff: source identity, resolved inputs, build worker, artifact identity, test result, signature/attestation, promotion, and update consumer.
|
||||||
|
- CI configuration is authorization code. Establish which event triggered a workflow, whose code runs, which secrets and tokens exist, and what it may publish or mutate.
|
||||||
|
- A checksum fetched from the same untrusted location as the artifact does not establish independent integrity. Identify the trusted root and failure behavior.
|
||||||
|
- Use `confirmed` for in-repo control-flow failures with bounded local validation. Use `needs_validation` for branch protection, hosted-runner, registry, signing-service, or production promotion facts that are not observable.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency and build-input attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Dependency source and namespace confusion**
|
||||||
|
Resolver configuration can select an unintended public/private namespace, fallback registry, mirror, repository, or source URL. Review package names, source priority, lockfile and checksum use, alternate build files, platform-specific resolution, and first-install versus update behavior.
|
||||||
|
|
||||||
|
**Mutable and unbound build inputs**
|
||||||
|
Builds consume branches, tags, unverified submodules, downloaded tools, generated assets, remote includes, floating CI actions, or container tags whose content can change without source review. Require a lower-trust writer and a path into trusted build output; reproducibility by itself does not prove authenticity.
|
||||||
|
|
||||||
|
**Generated-source and codegen provenance gaps**
|
||||||
|
Schemas, vendored archives, generated clients, localization, documentation examples, or binary blobs produce executable or shipped content without the same review and integrity gate as source. Compare local regeneration with committed output and verify who controls input and generator.
|
||||||
|
|
||||||
|
**Build-context inclusion**
|
||||||
|
Secrets, local configuration, repository metadata, test fixtures, or developer artifacts enter a package or image because the build context and ignore rules exceed intended release inputs. Confirm that the resulting artifact exposes a real credential, private data, or privileged configuration.
|
||||||
|
|
||||||
|
## CI and automation attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Untrusted code in a privileged workflow**
|
||||||
|
A pull request, issue comment, fork, dependency update, or external event runs contributor-controlled code with protected secrets, write tokens, deployment authority, or a trusted runner. Compare trigger type, checkout ref, approval gate, environment protection, and permission narrowing. Do not assume repository-host defaults that are not in source.
|
||||||
|
|
||||||
|
**Workflow command and expression confusion**
|
||||||
|
Attacker-controlled branch names, commit messages, issue fields, artifact names, matrix values, or generated output enter shell commands, template expressions, paths, or privileged workflow inputs without canonical validation.
|
||||||
|
|
||||||
|
**Cache, artifact, and workspace trust mixing**
|
||||||
|
A lower-trust job can populate a cache, artifact, shared workspace, or output that a higher-trust job later restores and executes or releases. Review cache keys and namespaces, artifact producer identity, digest binding, retention, and whether promotion re-resolves by mutable name.
|
||||||
|
|
||||||
|
**Automation identity overreach**
|
||||||
|
CI jobs receive permissions beyond the operation, repository, environment, or duration needed, and untrusted job inputs can select the affected resource. Missing least privilege alone is hardening; require a reachable privileged action.
|
||||||
|
|
||||||
|
## Release and update attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Build-to-promotion substitution**
|
||||||
|
Tests, review, signature, and publication refer to mutable tags, filenames, channels, or artifact IDs rather than the same immutable digest. Check every copy, repack, architecture merge, and provenance step between build and release.
|
||||||
|
|
||||||
|
**Release authorization and signing-policy gaps**
|
||||||
|
A release or signature is accepted from the wrong workflow, repository, branch, environment, key role, or threshold. Review identity claims inside attestations and verify the consumer validates them, not just a valid signature. Rotation, expiry, and revocation must fail closed where policy requires.
|
||||||
|
|
||||||
|
**Update metadata and rollback confusion**
|
||||||
|
An updater authenticates payload bytes but not version, product, platform, channel, target path, expiry, or rollback state, or it accepts metadata and payload from different authorized transactions. Verify atomic installation and recovery behavior. A signature API call without policy binding is incomplete.
|
||||||
|
|
||||||
|
**Plugin and extension trust expansion**
|
||||||
|
An extension package gains host authority beyond its declared scope, a lower-trust publisher can replace another publisher's identity, or install/update hooks run before authenticity and capability checks. Intended installation of arbitrary same-user plugins is not a privilege boundary.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Walk backward from a released digest or installed update to every source, generated input, credential, worker, cache, test result, and authorization decision.
|
||||||
|
- Compare untrusted and protected workflow events side by side. Mark each persisted channel crossing between them and require an immutable identity plus producer trust.
|
||||||
|
- Review revoked key, failed download, missing attestation, partial platform release, rollback, and registry outage paths. The failure policy is part of release integrity.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Name the lower-trust actor, controllable source/cache/artifact/metadata, consuming trusted job or updater, and resulting unauthorized publication, code inclusion, secret disclosure, or privileged execution.
|
||||||
|
2. Prove artifact identity across the broken handoff. A different mutable name or unbound digest must reach a real consumer.
|
||||||
|
3. Verify built-in package-manager, repository-host, registry, and signing defaults for the pinned version. Unknown hosted controls require `needs_validation`.
|
||||||
|
4. Keep local validation bounded: use a harmless fixture repository, dummy credential marker, local registry/config, and non-production artifact namespace. Do not publish or alter a real release.
|
||||||
|
5. Return `confirmed` only with a complete source-visible handoff and meaningful result. Return `needs_validation` with the precise branch, runner, registry, signing, or deployment fact an owner must observe.
|
||||||
186
.agents/skills/security-audit/VALIDATION-AND-REPORTING.md
Normal file
186
.agents/skills/security-audit/VALIDATION-AND-REPORTING.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# Validation, Structured Output, Verification, and Reporting
|
||||||
|
|
||||||
|
### Phase 3: Independently validate every candidate
|
||||||
|
|
||||||
|
After the clean coverage-critic pass or an explicitly recorded early stop, consolidate Phase 2 candidates and carried same-source prior confirmations by stable fingerprint and root cause. Give every unique proposed `confirmed` and `needs_validation` candidate to a fresh `general` verifier that did not hunt it. A carried prior confirmation follows the same current verification path even though hunters exclude that unchanged root cause. A verifier may read hunter or prior artifacts but must re-read every cited current source location and independently run any decisive check it can reproduce safely.
|
||||||
|
|
||||||
|
Assign each verifier a canonical lowercase unique ID and `<output-dir>/agents/<verifier-id>/scratch/` plus parent-owned `artifacts/`. The verifier writes only to `scratch/` and never writes retained artifacts. It receives only the candidate, its linked coverage-unit checks and artifact paths, architecture facts needed to interpret the path, exact relevant companion validation blocks, the promotion procedure block below, the source/local execution boundary, the `confirmed`, `needs_validation`, and `rejected` branches of `report-schema.json` copied verbatim, and prior records with the same fingerprint. It must not receive another verifier's conclusion.
|
||||||
|
|
||||||
|
#### Candidate-verifier prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
You did not write this candidate. Try to refute it from repository source and bounded
|
||||||
|
local evidence. Do not contact deployed endpoints or external/shared services. Run
|
||||||
|
target-controlled code only inside the approved OS-enforced sandbox: no external
|
||||||
|
network, empty allowlisted environment, read-only target and tools, scratch-only
|
||||||
|
writes, and explicit low resource and wall-clock limits. If any control is unavailable,
|
||||||
|
do not execute; retain the exact missing capability as a needs_validation blocker.
|
||||||
|
Treat every scratch entry as target-controlled after execution. After the sandbox and
|
||||||
|
all its processes terminate, only trusted parent-side code may promote a predeclared
|
||||||
|
scratch-relative file, following the promotion procedure block included verbatim in
|
||||||
|
this prompt. You and target code never write retained artifacts. If promotion is
|
||||||
|
unavailable or fails, do not use that file as evidence.
|
||||||
|
|
||||||
|
1. Verify every trace and evidence file, positive line number, scope, and description.
|
||||||
|
Confirm the first entry is a real lower-trust entrypoint and the last is the
|
||||||
|
claimed sink or boundary effect.
|
||||||
|
2. Reconstruct the strongest source-visible validation, identity, authorization,
|
||||||
|
normalization, lifecycle, framework, and containment controls on the path.
|
||||||
|
Where the architecture summary names a comparable baseline, note whether it
|
||||||
|
shares the pattern — as calibration, never as grounds to dismiss.
|
||||||
|
3. For a proposed confirmed candidate, independently reproduce the minimum observed
|
||||||
|
result when possible. Verify inputs, interface shape, conditions, and affected
|
||||||
|
dummy principal/resource. Do not infer a stronger result or continue after it.
|
||||||
|
4. Verify that likelihood, impact, confidence, and the proposed source fix match only
|
||||||
|
what the evidence establishes.
|
||||||
|
5. For a proposed needs_validation candidate, decide whether the blocker is genuinely
|
||||||
|
outside source/local observation. If source refutes the trace, reject it. If the
|
||||||
|
missing fact remains decisive, keep needs_validation and make the local and
|
||||||
|
owner-observed plans exact and non-destructive.
|
||||||
|
6. Preserve the fingerprint for the same source-derived root cause across every state.
|
||||||
|
|
||||||
|
Return exactly one JSON object and no surrounding prose:
|
||||||
|
{"decision": "confirmed|needs_validation|rejected", "record": { ... }}
|
||||||
|
where record exactly matches the decision's verdict branch of the schema included
|
||||||
|
in this prompt. A corrected record replaces the hunter's wording.
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy this promotion procedure verbatim into every candidate-verifier prompt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Artifact promotion procedure (trusted parent-side code only):
|
||||||
|
Reference only for you: the parent performs these steps; you never perform them.
|
||||||
|
|
||||||
|
Before execution, the parent opens and retains trusted, non-inheritable directory
|
||||||
|
descriptors for the agent's scratch/ and artifacts/ roots, and records an allowlist
|
||||||
|
of expected scratch-relative artifact files plus explicit per-file and cumulative
|
||||||
|
byte limits. Never pass those descriptors to the agent or sandbox. After the sandbox
|
||||||
|
and all its processes terminate, trusted parent-side code promotes each allowlisted
|
||||||
|
file separately:
|
||||||
|
|
||||||
|
1. Validate the declared relative path: reject absolute, empty, `.`, `..`, or
|
||||||
|
symlinked components.
|
||||||
|
2. Walk each parent component from the retained scratch-root descriptor with
|
||||||
|
no-follow directory-relative operations; never reopen by path.
|
||||||
|
3. Open the leaf no-follow and nonblocking.
|
||||||
|
4. Verify with `fstat` that it is a regular file with link count exactly one and
|
||||||
|
within the recorded per-file and cumulative byte limits.
|
||||||
|
5. Enforce those limits again while reading from that descriptor.
|
||||||
|
6. Copy exactly the verified size, repeat `fstat`, and reject a changed identity,
|
||||||
|
type, link count, or size.
|
||||||
|
7. For the destination, walk every parent component from the retained
|
||||||
|
artifacts-root descriptor with no-follow directory-relative operations; require
|
||||||
|
each existing component to be a real directory, and create any missing directory
|
||||||
|
exclusively before reopening and verifying it no-follow.
|
||||||
|
8. Create the leaf exclusively without following links, verify that the opened
|
||||||
|
destination is a regular file with link count exactly one, and copy from the
|
||||||
|
verified source descriptor without reopening either path.
|
||||||
|
9. Use equivalent race-safe APIs on non-POSIX systems.
|
||||||
|
10. Never recursively copy or glob scratch, extract an archive into artifacts, or
|
||||||
|
open or promote a symlink, FIFO, socket, device, directory, hard-linked file,
|
||||||
|
changing file, or file that exceeds its bound.
|
||||||
|
11. If any check is unavailable, cannot be enforced, or fails, discard the scratch
|
||||||
|
entry; if it is decisive evidence, retain `needs_validation` with the exact
|
||||||
|
promotion blocker.
|
||||||
|
```
|
||||||
|
|
||||||
|
A verifier can promote `needs_validation` to `confirmed` only after independently establishing the complete path and bounded observed result. Demote proposed confirmation to `needs_validation` when a specific deployment or runtime fact remains unknown. Use `rejected` when source, local behavior, a visible control, missing meaningful impact, or an impossible prerequisite refutes the claim. `needs_validation` is never a parking place for a speculative idea.
|
||||||
|
|
||||||
|
The parent checks that each verifier returned the same fingerprint unless it identified a genuinely different root cause. Merge corrections, record the decision in every linked coverage unit, and ensure there is one final record per fingerprint. Discard a malformed or prose-wrapped verifier result without repairing it; re-run that candidate with a fresh verifier when the budget permits, otherwise it remains an unvalidated ledger candidate under the incomplete-run rule.
|
||||||
|
|
||||||
|
When verifier evidence updates a ledger check, set that check's `agent_id` to the verifier's canonical ID and list its nonempty repository-relative `reviewed_paths`. Keep the unit-level `reviewed_paths` equal to the union across checks. Use `method: "source"` with `artifact: null` for source-only review. Use `method: "local"` only with a file successfully promoted by trusted parent-side code below `agents/<check.agent_id>/artifacts/`. The unit retains its original assignment owner, so independently owned hunter and verifier checks can coexist. For a carried prior record's seeded `planned` unit there is no prior owner: the verifier that re-checks it becomes the unit's assignment owner, and its re-check is the unit's first check, moving the unit to `candidate` with the carried fingerprint.
|
||||||
|
|
||||||
|
If a strict total-agent budget cannot cover every candidate, set the run status to incomplete and follow the deterministic budget rule in `SKILL.md`. An unvalidated candidate remains only in the ledger. It does not enter `findings.json` under any verdict.
|
||||||
|
|
||||||
|
### Phase 4: Write and validate `findings.json`
|
||||||
|
|
||||||
|
The parent writes all independently decided records to `<output-dir>/findings.json`, sorted by fingerprint. Include:
|
||||||
|
|
||||||
|
- `confirmed`: source-grounded vulnerabilities with complete local execution evidence, conditions, specific remediation, likelihood/impact/overall severity, and confidence.
|
||||||
|
- `needs_validation`: source-grounded candidates with an exact unresolved blocker and at least one applicable local or owner-observed deployment plan.
|
||||||
|
- `rejected`: source-grounded candidates disproved during validation, retained so future runs do not repeat the unsupported claim without changed evidence.
|
||||||
|
|
||||||
|
Read `report-schema.json` immediately before writing. It uses `additionalProperties: false`; do not carry hunter wrapper fields into a record. Keep these verdict contracts distinct:
|
||||||
|
|
||||||
|
- A `confirmed` record uses `root_cause`, `intended_behavior`, `conditions`, `execution`, `remediation`, `severity`, and `confidence`. It must not use `claimed_root_cause`, `blockers`, `validation_plan`, or `reason`. `execution` is target-neutral and uses the target's native interface: API/HTTP input, CLI call, library call, message, file fixture, browser action, rendered policy, or local harness as applicable. `observed_result` is nonempty and factual.
|
||||||
|
- A `needs_validation` record uses `claimed_root_cause`, `trace`, `evidence`, `blockers`, and at least one nonempty `validation_plan.local` or `validation_plan.deployment` field. Include both only when both contexts can resolve distinct facts. It must not use severity, execution, remediation, reason, or confirmed root cause.
|
||||||
|
- A `rejected` record uses `claimed_root_cause`, `trace`, `evidence`, and `reason`. It must not use severity, execution, remediation, blockers, validation plan, or confirmed root cause.
|
||||||
|
|
||||||
|
Every record has a stable fingerprint, title, description, and repository-relative source paths. A multi-step trace begins with `entrypoint`, ends with `sink`, and uses `propagation` only between them. One-entry traces use `entrypoint` or `sink`. Overall severity cannot exceed demonstrated impact.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node <skill-dir>/validate-findings.cjs <output-dir>/findings.json
|
||||||
|
node <skill-dir>/validate-coverage-ledger.cjs <output-dir>/coverage-ledger.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix every structural and semantic error before continuing. The findings validator rejects input beyond 5 MiB, 1,000 top-level findings, or 64 nesting levels, and caps reported error output at 100 messages. Validator success proves format and ledger consistency only.
|
||||||
|
|
||||||
|
### Phase 5: Verify the final records with fresh eyes
|
||||||
|
|
||||||
|
Launch one fresh `research` verifier per final `confirmed` and `needs_validation` record, in parallel. This verifier checks the structured record, not the hunter write-up, and remains inside source/local boundaries.
|
||||||
|
|
||||||
|
In a `quick` run, Phase 3 and Phase 5 merge: the Phase 3 verifier also performs these record checks and returns the final schema-shaped record, so each candidate gets one fresh independent reviewer instead of two. Every other profile keeps the two passes separate. Never skip independent review of a `confirmed` record in any profile.
|
||||||
|
|
||||||
|
For `confirmed`, require it to check:
|
||||||
|
|
||||||
|
1. Every repository-relative trace/evidence path, line, scope, and described operation.
|
||||||
|
2. Real entry interface and exact local input shape.
|
||||||
|
3. Every condition, parser/policy step, source-visible preventing layer, and observed local result.
|
||||||
|
4. Affected principal/resource and demonstrated impact.
|
||||||
|
5. Severity separation: realistic likelihood, demonstrated impact, overall no greater than impact.
|
||||||
|
6. Remediation strategy and any `code_changes`, including whether the fix enforces the invariant without merely moving trust.
|
||||||
|
|
||||||
|
For `needs_validation`, require it to check:
|
||||||
|
|
||||||
|
1. The source path is real and supports only the `claimed_root_cause` stated.
|
||||||
|
2. Every listed blocker is decisive and not already answerable locally.
|
||||||
|
3. The candidate names a boundary and a possible concrete result rather than a generic concern.
|
||||||
|
4. At least one validation-plan field is present and exact. `local` uses a bounded fixture; `deployment` asks an owner to observe a configuration, identity, route, policy, or runtime fact. Do not invent a plan for an inapplicable context, and never send audit traffic to a deployment.
|
||||||
|
5. The fingerprint matches prior/current records for the same root cause.
|
||||||
|
|
||||||
|
Each verifier returns exactly one JSON object: `{"decision":"verified","fingerprint":"..."}` or `{"decision":"replace","reason":"...","record":{...}}`, with no surrounding prose. A replacement record must match its `confirmed`, `needs_validation`, or `rejected` schema branch. Treat a malformed or prose-wrapped Phase 5 result the same way as in Phase 3: discard it without repairing it and re-run with a fresh verifier when the budget permits.
|
||||||
|
|
||||||
|
Do not apply a Phase 5 replacement as final when it promotes a record to a stronger verdict, including any promotion to `confirmed`, or materially changes the root cause, trace, execution input or observed result, demonstrated impact, or severity. Give that complete replacement to a new independent verifier that did not hunt, perform Phase 3 validation, or propose the Phase 5 replacement. The new verifier rechecks the current source and independently reproduces any decisive local result under the execution boundary, then returns `verified` or another replacement. Apply a material replacement only after this fresh verification. If another material replacement results, repeat with a fresh verifier. If budget or independence is unavailable, remove the disputed record from `findings.json`, keep its ledger unit as an unresolved candidate, and set `run_status: "incomplete"` with an exact `incomplete_reason`. Non-material wording or repository-line corrections may be applied directly when they do not change meaning or evidence.
|
||||||
|
|
||||||
|
After every applied replacement, rerun both validators and update linked ledger decisions. If a final verifier identifies a separate root cause, assign a new fingerprint and send it through independent candidate validation before inclusion. Set `run_status: "complete"` only when every ledger candidate has an independent final disposition and every retained record passes Phase 5.
|
||||||
|
|
||||||
|
Do not verify only `confirmed` records. A misleading `needs_validation` handoff wastes owner time and can preserve a false premise.
|
||||||
|
|
||||||
|
### Phase 6: Produce target-neutral reports from final records
|
||||||
|
|
||||||
|
Only after Phase 5 passes for every record retained in `findings.json`, derive prose from the final records, the ledger, and the hunter `hardening` notes retained in ledger bookkeeping. An incomplete run may report independently verified records, but it must identify each unresolved ledger candidate and must not present it as a finding. The prose files never change a verdict, severity, blocker, or demonstrated impact.
|
||||||
|
|
||||||
|
#### `REPORT.md`
|
||||||
|
|
||||||
|
Write:
|
||||||
|
|
||||||
|
1. Run profile, scope, budget (if set) with agents spent versus planned, source ref, sandboxed source-and-local-only execution statement, prior-run use, and explicit deferred and out-of-scope coverage. Name carried same-source confirmations and changed-source revalidations. A `quick`, scoped, budget-limited, or incomplete run states plainly that it is a partial pass. If candidate validation exhausted a strict budget, state that the run is incomplete and list every unvalidated fingerprint and linked unit; do not describe those candidates as findings. If the budget prevented a mandatory critic, state which critic did not run and make no clean-coverage claim.
|
||||||
|
2. One short security posture summary.
|
||||||
|
3. A confirmed-findings table: severity, title, affected boundary, and one-line observed result.
|
||||||
|
4. Each confirmed finding: repository source location, lower-trust principal, target-native bounded reproduction, conditions, actual result, impact, priority rationale, and smallest source fix.
|
||||||
|
5. A separate `NEEDS VALIDATION` table. Give each lead's title, repository trace, exact blocker, bounded local next step, and safe owner-observed deployment check. Do not assign severity or call it a confirmed vulnerability.
|
||||||
|
6. Separate hardening notes and positive source patterns.
|
||||||
|
7. Coverage summary from the ledger: covered, candidate, blocked, and deferred counts, plus important exclusions and the final critic result.
|
||||||
|
|
||||||
|
Do not describe rejected records as findings. Mention their fingerprints only when they explain a prior disagreement or coverage decision.
|
||||||
|
|
||||||
|
#### `FINDINGS-DETAIL.md`
|
||||||
|
|
||||||
|
For each confirmed `medium`, `high`, or `critical` record, copy the complete source path and target-neutral local reproduction:
|
||||||
|
|
||||||
|
- ordered repository-relative trace and evidence;
|
||||||
|
- dummy attacker/principal and affected dummy resource;
|
||||||
|
- native input, invocation, or fixture and exact bounded instructions;
|
||||||
|
- observed output and the security invariant it proves;
|
||||||
|
- conditions and containment;
|
||||||
|
- source-level remediation and regression case.
|
||||||
|
|
||||||
|
#### `NEEDS-VALIDATION.md`
|
||||||
|
|
||||||
|
For every unresolved record, copy the source trace, verified evidence, exact blocker, affected boundary, and each applicable bounded local or owner-observed resolution plan. Keep these as prioritized leads without severity. Do not turn them into live test guidance or assume the missing deployment fact.
|
||||||
|
|
||||||
|
HTTP is one possible native interface, not the default. A library finding may use a function call, a parser a fixture, a CLI a command, a desktop app an IPC or file action, and infrastructure a locally rendered policy. Do not require an endpoint, external account, or live environment that the target does not have.
|
||||||
|
|
||||||
|
Keep the report proportional to the evidence. A clean run may have zero confirmed records. State that result and the remaining coverage/validation limits without inventing LOW findings.
|
||||||
105
.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md
Normal file
105
.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# HTTP-Protocol and Authentication Hunting
|
||||||
|
|
||||||
|
#### When to use this file
|
||||||
|
|
||||||
|
Reach for this file when the target speaks HTTP at a parsing, caching, browser-authentication, or identity boundary: web applications, APIs, reverse proxies, CDNs, gateways, custom HTTP servers, and services implementing sessions, JWT, OAuth/OIDC, SAML, password recovery, MFA, passkeys, API keys, or mTLS. Use this with `ATTACK-CLASSES.md`: access-control review asks whether a principal may perform an operation; this file asks whether the HTTP or identity layer can confuse which principal, request, assurance level, or token the operation belongs to.
|
||||||
|
|
||||||
|
Pick classes from Phase 1. Split a large target into request framing and cache policy, browser authentication, federated identity, strong authentication and recovery, service credentials, and session lifecycle. A single server behind an unobserved managed proxy has little source-confirmable smuggling surface; a proxy or custom parser has much more.
|
||||||
|
|
||||||
|
## Core discipline (include in every agent prompt for this domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
- Framing and cache findings require two interpretations of the same request, response, or key. Name both components and the exact normalized value on each side.
|
||||||
|
- For every credential, find the signature or secret verification and every binding required for its role: issuer, audience, origin, RP, client, session, principal, resource, assurance, expiry, and one-time state.
|
||||||
|
- Host, Forwarded, X-Forwarded-*, Origin, Referer, redirect targets, callback state, and request-derived URLs are trust decisions. Trace each to the affected identity or response.
|
||||||
|
- A missing header, cookie attribute, MFA prompt, or rate limit is not a finding alone. Require an accepted invalid request, cross-principal impact, assurance downgrade, or credential disclosure.
|
||||||
|
- Classify `confirmed` only from complete source evidence and bounded local request/token tests. Use `needs_validation` when proxy, IdP, browser, certificate, secret, or deployed configuration is required but not visible.
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP framing and cache attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Request framing and desynchronization**
|
||||||
|
Front end and back end disagree on request length or header normalization. Review multiple `Content-Length` values, `Transfer-Encoding`, HTTP/2 or HTTP/3 downgrade, header-name normalization, forbidden connection headers, and CR/LF conversion. Confirm which bytes one component assigns to a request and which bytes its peer assigns to the next request.
|
||||||
|
|
||||||
|
**Web cache poisoning through unkeyed input**
|
||||||
|
A request value changes cached content or security-relevant headers but is absent from the cache key. Compare cache key construction with every response variant, including forwarded host/scheme, selected cookies, query normalization, language/device headers, and authorization state.
|
||||||
|
|
||||||
|
**Cache deception and private-response caching**
|
||||||
|
Cache routing treats a private dynamic path as a public static asset, or caches a response whose identity and authorization inputs are missing from policy. Compare edge cacheability with application route parsing, suffix/path-parameter normalization, and response cache directives.
|
||||||
|
|
||||||
|
**Host and forwarded-header trust**
|
||||||
|
Untrusted host/proxy metadata determines absolute URLs, tenant routing, callbacks, reset links, cache keys, or the client address used by authorization. Confirm who can supply the header and whether trusted ingress removes client-provided copies.
|
||||||
|
|
||||||
|
**Response-header injection**
|
||||||
|
Untrusted data reaches `Location`, `Set-Cookie`, CSP, or another response header with unsafe control characters or normalization. Verify framework rejection before reporting and require a security-relevant response change.
|
||||||
|
|
||||||
|
## Browser-session attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**Ordinary CSRF**
|
||||||
|
A browser sends ambient credentials to a state-changing endpoint that accepts a cross-site request without an effective anti-CSRF token, same-site request binding, or strict Origin/Referer validation. Inventory every cookie-authenticated mutation, including form, JSON-like, multipart, method-override, and legacy routes. SameSite is effective only for the cookie and browser contexts actually used; login CSRF and cross-site subresource requests can have different requirements.
|
||||||
|
|
||||||
|
**Session fixation and invalidation**
|
||||||
|
Session identifiers are not rotated on login, account switch, MFA completion, impersonation, or other privilege changes, or remain valid after logout, password change, revocation, and account disable. Check server sessions, refresh tokens, signed cookies, websocket state, cache copies, and fallback endpoints.
|
||||||
|
|
||||||
|
**Cookie scope and transport**
|
||||||
|
A sensitive cookie has an over-broad `Domain` or `Path`, can cross an insecure transport, or conflicts with a sibling cookie that another component selects differently. Bare missing flags remain hardening notes unless a realistic less-trusted origin, network position, or browser path can gain or replace the credential.
|
||||||
|
|
||||||
|
## Federated-identity attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
First establish role. Authorization-server controls such as redirect allowlisting and code issuance do not belong to a relying-party client. Verification and binding defects belong to the component consuming the artifact.
|
||||||
|
|
||||||
|
**JWT verification and claim binding**
|
||||||
|
Check signature verification, server-pinned algorithm and key source, then `exp`, `nbf`, `aud`, and `iss`. Review `kid`, `jku`, and `x5u` as untrusted key selectors, duplicate/header normalization, and decode-without-verify paths. A valid token for another service is invalid here even when signed by a trusted issuer.
|
||||||
|
|
||||||
|
**OAuth/OIDC request and callback binding**
|
||||||
|
Validate exact `redirect_uri` ownership where the target is the authorization server; session-bound `state`; PKCE and authorization-code binding where applicable; ID-token issuer/audience/signature/nonce; and selected-IdP binding in multi-provider flows. Compare initial callback, retry, mobile/deep-link, and account-link routes.
|
||||||
|
|
||||||
|
**SAML signed-object and assertion binding**
|
||||||
|
Ensure the element whose signature is validated is the element used as identity. Review unsigned/fallback paths, safe XML parser configuration, canonicalization differences, and freshness/binding fields such as validity windows, audience/recipient, request correlation, and replay state.
|
||||||
|
|
||||||
|
## MFA, passkey, and account-transition attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**MFA enrollment and assurance downgrade**
|
||||||
|
Enrollment, replacement, disablement, recovery-code generation, trusted-device creation, and fallback login require the intended prior assurance. Check that a valid first factor cannot enroll or replace the second factor without policy-required fresh authentication, and that disabled or stale factors stop authorizing sessions.
|
||||||
|
|
||||||
|
**Step-up binding and bypass**
|
||||||
|
A successful challenge upgrades the wrong session, account, tenant, action, or API request, or an alternate route omits the assurance check. Bind the challenge to principal, current session, assurance target, operation or resource when required, expiry, and one-time completion. Compare UI, API, batch, recovery, and resumed-flow paths.
|
||||||
|
|
||||||
|
**WebAuthn and passkey verification**
|
||||||
|
At registration, bind challenge, RP ID, expected origin, credential, user/userHandle, algorithm, and policy-required user verification to the initiating session. At authentication, verify challenge, RP/origin, credential membership, signature, and intended user presence/verification. Check account-discovery and linking flows for userHandle or credential-to-account confusion. Signature-counter handling is meaningful only when the product treats regressions as a clone signal.
|
||||||
|
|
||||||
|
**Account linking and identity collision**
|
||||||
|
Adding an IdP, passkey, email, phone, device, or external account to an existing account must require a current authenticated session, verified ownership of the new identity, policy-required step-up, and callback state bound to the account that initiated linking. Review unlink/relink and invite-acceptance paths for verified-identifier or tenant collisions.
|
||||||
|
|
||||||
|
**Password reset and broader recovery**
|
||||||
|
Recovery tokens, support/admin recovery, backup codes, device migration, and email or phone change often become the weakest authentication path. Verify token randomness, user/action binding, expiry, one-time state, rate/accounting controls, delivery URL trust, and invalidation of prior tokens and sessions. Different responses that only reveal public account existence are not automatically security findings.
|
||||||
|
|
||||||
|
## API-key and mTLS attack classes (subagent_type: `general`)
|
||||||
|
|
||||||
|
**API-key scope and resource binding**
|
||||||
|
A key authenticates to broader tenants, resources, actions, or environments than its server-side record grants, or request parameters override those bindings. Review key lookup, prefix/full-secret verification, type confusion between publishable and secret keys, scope checks, rotation, revocation caches, and bulk endpoints.
|
||||||
|
|
||||||
|
**API-key exposure and unsafe transport**
|
||||||
|
Keys appear in client bundles, URLs, redirects, logs, error paths, build artifacts, or responses accessible to a lower-trust principal. A public identifier called a key is not a secret. Confirm key type and the authority gained by disclosure.
|
||||||
|
|
||||||
|
**mTLS peer and application-identity confusion**
|
||||||
|
A process trusts client-certificate identity headers from any network peer, verifies a chain but maps attacker-influenceable subject text to an account incorrectly, or accepts a certificate for the wrong trust domain, extended usage, audience, or validity policy. Where a trusted proxy terminates mTLS, verify only that proxy can connect, it removes incoming identity headers, and the backend binds the sanitized identity to the request.
|
||||||
|
|
||||||
|
**Certificate lifecycle fallback**
|
||||||
|
Expired, revoked, missing, or renewal-failed certificates cause silent fallback to bearer-only or anonymous operation, or long-lived pooled connections retain authorization after revocation. Missing deployment revocation data makes the result `needs_validation`; an in-repo fail-open branch is source-confirmable.
|
||||||
|
|
||||||
|
## Universal moves (apply across the above)
|
||||||
|
|
||||||
|
- Walk issue → store → transmit → consume → refresh → revoke for every credential and challenge. Compare normal, error, retry, migration, legacy, and account-switch paths.
|
||||||
|
- Enumerate every door to the same identity and every route to the same sensitive operation. The effective policy is the weakest parallel path, not the most polished UI.
|
||||||
|
- Diff parser, proxy, router, cache, and application normalization side by side. For local validation, feed identical bounded request fixtures into each component rather than sending traffic to a live deployment.
|
||||||
|
- For recovery and linking, draw the account before/after graph. Each edge must name the current principal, proof of the new identity, required assurance, callback/session binding, and revocation effect.
|
||||||
|
|
||||||
|
## Validation rules (apply before reporting ANY finding here)
|
||||||
|
|
||||||
|
1. Apply a source-visibility gate. Proxy chains, edge cache keys, IdP policy, certificate trust, browser cookie behavior, secrets, and deployed auth modes may be outside the repository. Record a precise `needs_validation` candidate instead of asserting missing infrastructure behavior.
|
||||||
|
2. For framing and cache findings, name both components and the divergent parse/key. Confirm cross-request, cross-user, or private-response impact with bounded local fixtures.
|
||||||
|
3. For token, MFA, passkey, account-link, recovery, API-key, and mTLS findings, cite the verification line and missing principal/session/resource/origin/audience/action/assurance binding. Prove the server accepts the invalid transition or credential.
|
||||||
|
4. For CSRF, name the ambient credential, state-changing route, accepted cross-site request shape, browser cookie policy, and missing effective check. Read-only actions and routes requiring a non-ambient bearer token do not qualify.
|
||||||
|
5. Verify framework and library defaults. If version or configuration is unknown, use `needs_validation`; do not turn an unverified critical claim into a lower-severity confirmed finding.
|
||||||
|
6. Return `confirmed` only with a complete source trace and observable unauthorized identity, state, or disclosure. For `needs_validation`, name the missing fact and safe local or owner-observed check that resolves it.
|
||||||
461
.agents/skills/security-audit/report-schema.json
Normal file
461
.agents/skills/security-audit/report-schema.json
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Top-level contract for findings.json. validate-findings.cjs interprets and checks this schema directly.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "A source-grounded vulnerability that was independently demonstrated.",
|
||||||
|
"properties": {
|
||||||
|
"verdict": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "confirmed"
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$",
|
||||||
|
"description": "A stable source-derived identifier that does not change between validation states."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"root_cause": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"intended_behavior": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"trace": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["entrypoint", "propagation", "sink"]
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "file", "line", "scope", "description"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file", "line", "description"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"conditions": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["authentication_level", "authorization_role", "user_interaction", "system_configuration", "network_routing", "environmental_dependency", "data_state", "timing_dependency", "third_party_dependency"]
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "description"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Target-neutral reproduction in the target's native interface.",
|
||||||
|
"properties": {
|
||||||
|
"attacker_perspective": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"payloads": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observed_result": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["attacker_perspective", "payloads", "instructions", "observed_result"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"remediation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"code_changes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"fixed_code": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file_name", "fixed_code"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["strategy"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"likelihood": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"score": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["informational", "low", "medium", "high", "critical"]
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["score", "reason"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"impact": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"score": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["informational", "low", "medium", "high", "critical"]
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["score", "reason"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"overall_severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["informational", "low", "medium", "high", "critical"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["likelihood", "impact", "overall_severity"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"score": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high"]
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["score", "reason"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["verdict", "fingerprint", "title", "description", "root_cause", "intended_behavior", "trace", "evidence", "conditions", "execution", "remediation", "severity", "confidence"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "A source-grounded candidate whose decisive validation is blocked.",
|
||||||
|
"properties": {
|
||||||
|
"verdict": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "needs_validation"
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"claimed_root_cause": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"trace": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["entrypoint", "propagation", "sink"]
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "file", "line", "scope", "description"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file", "line", "description"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"blockers": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation_plan": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"local": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["verdict", "fingerprint", "title", "description", "claimed_root_cause", "trace", "evidence", "blockers", "validation_plan"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "A source-grounded candidate refuted during validation.",
|
||||||
|
"properties": {
|
||||||
|
"verdict": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "rejected"
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"claimed_root_cause": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"trace": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["entrypoint", "propagation", "sink"]
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "file", "line", "scope", "description"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file", "line", "description"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"visibleContent": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["verdict", "fingerprint", "title", "description", "claimed_root_cause", "trace", "evidence", "reason"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
872
.agents/skills/security-audit/validate-coverage-ledger.cjs
Executable file
872
.agents/skills/security-audit/validate-coverage-ledger.cjs
Executable file
@@ -0,0 +1,872 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates coverage-ledger.json and its canonical coverage IDs.
|
||||||
|
* Usage: node validate-coverage-ledger.cjs <path-to-coverage-ledger.json>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { TextDecoder } = require("node:util");
|
||||||
|
|
||||||
|
// Conservative bounds apply before JSON.parse and again to the parsed document.
|
||||||
|
const LIMITS = Object.freeze({
|
||||||
|
inputBytes: 5 * 1024 * 1024,
|
||||||
|
units: 10000,
|
||||||
|
collectionItems: 1000,
|
||||||
|
objectFields: 1000,
|
||||||
|
nestingDepth: 64,
|
||||||
|
preflightValues: 500000,
|
||||||
|
validationErrors: 100,
|
||||||
|
});
|
||||||
|
const MAX_INPUT_BYTES = LIMITS.inputBytes;
|
||||||
|
const MAX_UNITS = LIMITS.units;
|
||||||
|
const MAX_LIST_ITEMS = LIMITS.collectionItems;
|
||||||
|
const MAX_TEXT_LENGTH = 4096;
|
||||||
|
const REQUIRED_FIELDS = [
|
||||||
|
"coverage_id",
|
||||||
|
"canonical_refs",
|
||||||
|
"surface",
|
||||||
|
"boundary",
|
||||||
|
"subsystem",
|
||||||
|
"attack_class",
|
||||||
|
"starting_paths",
|
||||||
|
"ordinary_attack_class_block",
|
||||||
|
"selected_companion_blocks",
|
||||||
|
"excluded_blocks",
|
||||||
|
"prior_status",
|
||||||
|
"attempts",
|
||||||
|
"wave",
|
||||||
|
"status",
|
||||||
|
"agent_id",
|
||||||
|
"reviewed_paths",
|
||||||
|
"local_checks",
|
||||||
|
"result_fingerprints",
|
||||||
|
"unresolved",
|
||||||
|
];
|
||||||
|
const REF_FIELDS = ["surface", "boundary", "subsystem", "attack_class"];
|
||||||
|
const STATUSES = new Set([
|
||||||
|
"planned",
|
||||||
|
"not_applicable",
|
||||||
|
"out_of_scope",
|
||||||
|
"in_progress",
|
||||||
|
"covered",
|
||||||
|
"candidate",
|
||||||
|
"blocked",
|
||||||
|
"deferred",
|
||||||
|
]);
|
||||||
|
const ATTEMPT_STATUSES = new Set(["covered", "candidate", "blocked"]);
|
||||||
|
const ATTEMPT_FIELDS = [
|
||||||
|
"wave",
|
||||||
|
"status",
|
||||||
|
"agent_id",
|
||||||
|
"reviewed_paths",
|
||||||
|
"local_checks",
|
||||||
|
"result_fingerprints",
|
||||||
|
"unresolved",
|
||||||
|
"reassignment_reason",
|
||||||
|
];
|
||||||
|
const PRIOR_STATUSES = new Set([
|
||||||
|
"new",
|
||||||
|
"prior_confirmed_same_source",
|
||||||
|
"prior_confirmed_changed_source",
|
||||||
|
"prior_needs_validation",
|
||||||
|
"prior_deferred",
|
||||||
|
"prior_blocked",
|
||||||
|
"prior_out_of_scope",
|
||||||
|
"prior_covered_same_source",
|
||||||
|
"prior_covered_changed_source",
|
||||||
|
"prior_rejected_claim_changed",
|
||||||
|
"none",
|
||||||
|
]);
|
||||||
|
const FINGERPRINT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/;
|
||||||
|
const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
const WINDOWS_RESERVED_AGENT_ID = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
|
||||||
|
const VISIBLE_CONTENT = /[^\p{White_Space}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
|
||||||
|
const PATH_FORBIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
|
||||||
|
const WINDOWS_RESERVED_COMPONENT = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\.|$)/iu;
|
||||||
|
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||||
|
const UNSAFE_DIAGNOSTIC_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
|
||||||
|
const MAX_DIAGNOSTIC_STRING_LENGTH = 256;
|
||||||
|
|
||||||
|
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||||
|
|
||||||
|
class JsonStructureError extends Error {}
|
||||||
|
class SafeInputError extends Error {}
|
||||||
|
|
||||||
|
function escapeUnsafeDiagnosticCharacters(value) {
|
||||||
|
return String(value).replace(UNSAFE_DIAGNOSTIC_CHARACTER, (character) => {
|
||||||
|
const codePoint = character.codePointAt(0);
|
||||||
|
return codePoint <= 0xffff
|
||||||
|
? `\\u${codePoint.toString(16).padStart(4, "0")}`
|
||||||
|
: `\\u{${codePoint.toString(16)}}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeQuote(value) {
|
||||||
|
let serialized;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const clipped = value.length > MAX_DIAGNOSTIC_STRING_LENGTH
|
||||||
|
? `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...`
|
||||||
|
: value;
|
||||||
|
serialized = JSON.stringify(clipped);
|
||||||
|
} else if (value === null || typeof value === "boolean") {
|
||||||
|
serialized = String(value);
|
||||||
|
} else if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
serialized = String(value);
|
||||||
|
} else {
|
||||||
|
serialized = `"<${Array.isArray(value) ? "array" : typeof value}>"`;
|
||||||
|
}
|
||||||
|
return escapeUnsafeDiagnosticCharacters(serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createErrorList() {
|
||||||
|
const errors = [];
|
||||||
|
Object.defineProperty(errors, "push", {
|
||||||
|
value(...messages) {
|
||||||
|
const remaining = LIMITS.validationErrors - this.length;
|
||||||
|
if (remaining > 0) {
|
||||||
|
Array.prototype.push.apply(this, messages.slice(0, remaining).map(escapeUnsafeDiagnosticCharacters));
|
||||||
|
}
|
||||||
|
return this.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value) {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preflightJsonText(contents) {
|
||||||
|
const containers = [];
|
||||||
|
let rootState = "value";
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
let totalValues = 0;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new JsonStructureError(`input ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentContainer() {
|
||||||
|
return containers[containers.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function countValue() {
|
||||||
|
totalValues++;
|
||||||
|
if (totalValues > LIMITS.preflightValues) {
|
||||||
|
fail(`exceeds ${LIMITS.preflightValues} total value limit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginValue() {
|
||||||
|
const container = currentContainer();
|
||||||
|
if (!container) {
|
||||||
|
if (rootState !== "value") fail("has malformed JSON structure");
|
||||||
|
rootState = "end";
|
||||||
|
} else if (container.type === "array") {
|
||||||
|
if (container.state !== "firstValueOrEnd" && container.state !== "value") {
|
||||||
|
fail("has malformed JSON array structure");
|
||||||
|
}
|
||||||
|
container.items++;
|
||||||
|
const limit = container.topLevel ? MAX_UNITS : LIMITS.collectionItems;
|
||||||
|
if (container.items > limit) {
|
||||||
|
fail(container.topLevel
|
||||||
|
? `exceeds ${limit} top-level unit limit`
|
||||||
|
: `exceeds ${limit} item array limit`);
|
||||||
|
}
|
||||||
|
container.state = "commaOrEnd";
|
||||||
|
} else {
|
||||||
|
if (container.state !== "value") fail("has malformed JSON object structure");
|
||||||
|
container.state = "commaOrEnd";
|
||||||
|
}
|
||||||
|
countValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginString() {
|
||||||
|
const container = currentContainer();
|
||||||
|
if (container && container.type === "object" &&
|
||||||
|
(container.state === "firstKeyOrEnd" || container.state === "key")) {
|
||||||
|
container.fields++;
|
||||||
|
if (container.fields > LIMITS.objectFields) {
|
||||||
|
fail(`exceeds ${LIMITS.objectFields} field object limit`);
|
||||||
|
}
|
||||||
|
container.state = "colon";
|
||||||
|
} else {
|
||||||
|
beginValue();
|
||||||
|
}
|
||||||
|
inString = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginContainer(type) {
|
||||||
|
beginValue();
|
||||||
|
if (containers.length >= LIMITS.nestingDepth) {
|
||||||
|
fail(`exceeds nesting depth limit ${LIMITS.nestingDepth}`);
|
||||||
|
}
|
||||||
|
containers.push(type === "array"
|
||||||
|
? { type, state: "firstValueOrEnd", items: 0, topLevel: containers.length === 0 }
|
||||||
|
: { type, state: "firstKeyOrEnd", fields: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContainer(type) {
|
||||||
|
const container = currentContainer();
|
||||||
|
if (!container || container.type !== type) fail("has mismatched JSON containers");
|
||||||
|
const canClose = type === "array"
|
||||||
|
? container.state === "firstValueOrEnd" || container.state === "commaOrEnd"
|
||||||
|
: container.state === "firstKeyOrEnd" || container.state === "commaOrEnd";
|
||||||
|
if (!canClose) fail(`has malformed JSON ${type} structure`);
|
||||||
|
containers.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWhitespace(character) {
|
||||||
|
return character === " " || character === "\t" || character === "\r" || character === "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenDelimiter(character) {
|
||||||
|
return isWhitespace(character) || character === "," || character === ":" ||
|
||||||
|
character === "[" || character === "]" || character === "{" ||
|
||||||
|
character === "}" || character === "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < contents.length; index++) {
|
||||||
|
const character = contents[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (character === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
} else if (character === "\"") {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isWhitespace(character)) continue;
|
||||||
|
|
||||||
|
if (character === "\"") {
|
||||||
|
beginString();
|
||||||
|
} else if (character === "[") {
|
||||||
|
beginContainer("array");
|
||||||
|
} else if (character === "{") {
|
||||||
|
beginContainer("object");
|
||||||
|
} else if (character === "]") {
|
||||||
|
closeContainer("array");
|
||||||
|
} else if (character === "}") {
|
||||||
|
closeContainer("object");
|
||||||
|
} else if (character === ":") {
|
||||||
|
const container = currentContainer();
|
||||||
|
if (!container || container.type !== "object" || container.state !== "colon") {
|
||||||
|
fail("has malformed JSON object structure");
|
||||||
|
}
|
||||||
|
container.state = "value";
|
||||||
|
} else if (character === ",") {
|
||||||
|
const container = currentContainer();
|
||||||
|
if (!container || container.state !== "commaOrEnd") fail("has malformed JSON collection structure");
|
||||||
|
container.state = container.type === "array" ? "value" : "key";
|
||||||
|
} else {
|
||||||
|
beginValue();
|
||||||
|
while (index + 1 < contents.length && !isTokenDelimiter(contents[index + 1])) index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inString) fail("has an unterminated JSON string");
|
||||||
|
if (containers.length > 0) fail("has truncated JSON structure");
|
||||||
|
if (rootState !== "end") fail("has no JSON value");
|
||||||
|
}
|
||||||
|
|
||||||
|
function preflightDocument(root) {
|
||||||
|
const errors = createErrorList();
|
||||||
|
const stack = [{ value: root, depth: 0, location: "$" }];
|
||||||
|
let visited = 0;
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const { value, depth, location } = stack.pop();
|
||||||
|
visited++;
|
||||||
|
if (visited > LIMITS.preflightValues) {
|
||||||
|
errors.push(`$: exceeds ${LIMITS.preflightValues} total values`);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== "object") continue;
|
||||||
|
if (depth >= LIMITS.nestingDepth) {
|
||||||
|
errors.push(`${location}: exceeds nesting depth limit ${LIMITS.nestingDepth}`);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const limit = location === "$" ? MAX_UNITS : MAX_LIST_ITEMS;
|
||||||
|
if (value.length > limit) {
|
||||||
|
errors.push(`${location}: exceeds ${limit} entries`);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
for (let index = value.length - 1; index >= 0; index--) {
|
||||||
|
stack.push({ value: value[index], depth: depth + 1, location: `${location}[${index}]` });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (keys.length > LIMITS.objectFields) {
|
||||||
|
errors.push(`${location}: exceeds ${LIMITS.objectFields} object fields`);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
for (let index = keys.length - 1; index >= 0; index--) {
|
||||||
|
const key = keys[index];
|
||||||
|
stack.push({ value: value[key], depth: depth + 1, location: `${location}{${index}}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidUnicodeScalarValues(value) {
|
||||||
|
let index = 0;
|
||||||
|
while (index < value.length) {
|
||||||
|
const first = value.charCodeAt(index++);
|
||||||
|
if (first >= 0xd800 && first <= 0xdbff) {
|
||||||
|
if (index >= value.length) return false;
|
||||||
|
const second = value.charCodeAt(index++);
|
||||||
|
if (second < 0xdc00 || second > 0xdfff) return false;
|
||||||
|
} else if (first >= 0xdc00 && first <= 0xdfff) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasVisibleProse(value) {
|
||||||
|
return hasValidUnicodeScalarValues(value) && VISIBLE_CONTENT.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisibleText(value, maxLength = MAX_TEXT_LENGTH) {
|
||||||
|
return typeof value === "string" &&
|
||||||
|
value.length > 0 &&
|
||||||
|
value.length <= maxLength &&
|
||||||
|
value.trim() === value &&
|
||||||
|
hasVisibleProse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCanonicalRef(value) {
|
||||||
|
return isVisibleText(value, 1024) &&
|
||||||
|
!PATH_FORBIDDEN_CHARACTER.test(value) &&
|
||||||
|
value.normalize("NFC") === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCanonicalRef(value) {
|
||||||
|
if (!isCanonicalRef(value)) throw new TypeError("invalid canonical reference");
|
||||||
|
let encoded = "";
|
||||||
|
for (const byte of Buffer.from(value, "utf8")) {
|
||||||
|
const unreserved =
|
||||||
|
(byte >= 0x41 && byte <= 0x5a) ||
|
||||||
|
(byte >= 0x61 && byte <= 0x7a) ||
|
||||||
|
(byte >= 0x30 && byte <= 0x39) ||
|
||||||
|
byte === 0x2d || byte === 0x2e || byte === 0x5f || byte === 0x7e;
|
||||||
|
encoded += unreserved ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalCoverageId(refs) {
|
||||||
|
if (!isObject(refs)) throw new TypeError("canonical_refs must be an object");
|
||||||
|
const fields = hasOwn(refs, "lifecycle") ? [...REF_FIELDS, "lifecycle"] : REF_FIELDS;
|
||||||
|
if (Object.keys(refs).length !== fields.length || fields.some((field) => !hasOwn(refs, field))) {
|
||||||
|
throw new TypeError("canonical_refs has missing or unexpected fields");
|
||||||
|
}
|
||||||
|
return fields.map((field) => encodeCanonicalRef(refs[field])).join("::");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeRelativePath(value) {
|
||||||
|
if (typeof value !== "string" || value.length === 0 || !hasValidUnicodeScalarValues(value) || value.trim() !== value || PATH_FORBIDDEN_CHARACTER.test(value) || value.includes("\\") || value.includes(":")) return false;
|
||||||
|
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("~")) return false;
|
||||||
|
return value.split("/").every((segment) =>
|
||||||
|
segment !== "" &&
|
||||||
|
segment !== "." &&
|
||||||
|
segment !== ".." &&
|
||||||
|
!/[ .]$/u.test(segment) &&
|
||||||
|
!WINDOWS_RESERVED_COMPONENT.test(segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeAgentId(value) {
|
||||||
|
return typeof value === "string" &&
|
||||||
|
AGENT_ID_PATTERN.test(value) &&
|
||||||
|
!WINDOWS_RESERVED_AGENT_ID.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOwnedArtifactPath(value, agentId) {
|
||||||
|
if (!isSafeAgentId(agentId) || !isSafeRelativePath(value)) return false;
|
||||||
|
const prefix = `agents/${agentId}/artifacts/`;
|
||||||
|
return value.startsWith(prefix) && value.length > prefix.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStringArray(value, location, errors, options = {}) {
|
||||||
|
const { allowEmpty = true, fingerprint = false, pathValue = false } = options;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push(`${location}: expected array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!allowEmpty && value.length === 0) errors.push(`${location}: must not be empty`);
|
||||||
|
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
|
||||||
|
const seen = new Set();
|
||||||
|
value.slice(0, MAX_LIST_ITEMS).forEach((entry, index) => {
|
||||||
|
const entryLocation = `${location}[${index}]`;
|
||||||
|
const valid = pathValue ? isSafeRelativePath(entry) : isVisibleText(entry);
|
||||||
|
if (!valid) errors.push(`${entryLocation}: invalid ${pathValue ? "repository-relative path" : "text"}`);
|
||||||
|
if (fingerprint && typeof entry === "string" && !FINGERPRINT_PATTERN.test(entry)) {
|
||||||
|
errors.push(`${entryLocation}: invalid fingerprint`);
|
||||||
|
}
|
||||||
|
if (typeof entry === "string" && seen.has(entry)) errors.push(`${entryLocation}: duplicate entry`);
|
||||||
|
if (typeof entry === "string") seen.add(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateChecks(value, location, errors) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push(`${location}: expected array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
|
||||||
|
value.slice(0, MAX_LIST_ITEMS).forEach((check, index) => {
|
||||||
|
const base = `${location}[${index}]`;
|
||||||
|
if (!isObject(check)) {
|
||||||
|
errors.push(`${base}: expected object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const field of ["agent_id", "reviewed_paths", "invariant", "method", "result", "artifact"]) {
|
||||||
|
if (!hasOwn(check, field)) errors.push(`${base}: missing required field ${safeQuote(field)}`);
|
||||||
|
}
|
||||||
|
if (!isSafeAgentId(check.agent_id)) errors.push(`${base}.agent_id: expected a canonical lowercase agent ID`);
|
||||||
|
validateStringArray(check.reviewed_paths, `${base}.reviewed_paths`, errors, { allowEmpty: false, pathValue: true });
|
||||||
|
if (!isVisibleText(check.invariant)) errors.push(`${base}.invariant: invalid text`);
|
||||||
|
if (check.method !== "source" && check.method !== "local") errors.push(`${base}.method: expected "source" or "local"`);
|
||||||
|
if (!isVisibleText(check.result)) errors.push(`${base}.result: invalid text`);
|
||||||
|
if (check.method === "source" && check.artifact !== null) {
|
||||||
|
errors.push(`${base}.artifact: source-only check must use null`);
|
||||||
|
} else if (check.method === "local") {
|
||||||
|
if (!isOwnedArtifactPath(check.artifact, check.agent_id)) {
|
||||||
|
errors.push(`${base}.artifact: local check requires an artifact owned by agent ${safeQuote(isSafeAgentId(check.agent_id) ? check.agent_id : "<agent-id>")}`);
|
||||||
|
}
|
||||||
|
} else if (check.method !== "source" && check.method !== "local" && check.artifact !== null && !isSafeRelativePath(check.artifact)) {
|
||||||
|
errors.push(`${base}.artifact: expected null or a safe output-relative path`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReviewedPathOwnership(unit, base, errors) {
|
||||||
|
if (!Array.isArray(unit.reviewed_paths) || !Array.isArray(unit.local_checks)) return;
|
||||||
|
const aggregatePaths = new Set(unit.reviewed_paths.filter((value) => typeof value === "string"));
|
||||||
|
const ownedPaths = new Set();
|
||||||
|
for (const check of unit.local_checks) {
|
||||||
|
if (!isObject(check) || !Array.isArray(check.reviewed_paths)) continue;
|
||||||
|
for (const reviewedPath of check.reviewed_paths) {
|
||||||
|
if (typeof reviewedPath === "string") ownedPaths.add(reviewedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const reviewedPath of aggregatePaths) {
|
||||||
|
if (!ownedPaths.has(reviewedPath)) errors.push(`${base}.reviewed_paths: ${safeQuote(reviewedPath)} has no check owner`);
|
||||||
|
}
|
||||||
|
for (const reviewedPath of ownedPaths) {
|
||||||
|
if (!aggregatePaths.has(reviewedPath)) errors.push(`${base}.local_checks: owned path ${safeQuote(reviewedPath)} is absent from aggregate reviewed_paths`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExcludedBlocks(value, location, errors) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push(`${location}: expected array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
|
||||||
|
const seen = new Set();
|
||||||
|
value.slice(0, MAX_LIST_ITEMS).forEach((entry, index) => {
|
||||||
|
const base = `${location}[${index}]`;
|
||||||
|
if (!isObject(entry)) {
|
||||||
|
errors.push(`${base}: expected object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isVisibleText(entry.block)) errors.push(`${base}.block: invalid text`);
|
||||||
|
if (!isVisibleText(entry.reason)) errors.push(`${base}.reason: invalid text`);
|
||||||
|
if (typeof entry.block === "string" && seen.has(entry.block)) errors.push(`${base}.block: duplicate entry`);
|
||||||
|
if (typeof entry.block === "string") seen.add(entry.block);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticKey(unit) {
|
||||||
|
return JSON.stringify([
|
||||||
|
unit.surface,
|
||||||
|
unit.boundary,
|
||||||
|
unit.subsystem,
|
||||||
|
unit.attack_class,
|
||||||
|
hasOwn(unit, "lifecycle") ? unit.lifecycle : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidSemanticFields(unit) {
|
||||||
|
return ["surface", "boundary", "subsystem", "attack_class"].every((field) => isVisibleText(unit[field])) &&
|
||||||
|
(!hasOwn(unit, "lifecycle") || isVisibleText(unit.lifecycle));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEmptyArray(unit, field, base, errors) {
|
||||||
|
if (Array.isArray(unit[field]) && unit[field].length > 0) {
|
||||||
|
errors.push(`${base}.${field}: unit with status ${safeQuote(unit.status)} must keep this array empty`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireNonemptyArray(unit, field, base, errors) {
|
||||||
|
if (!Array.isArray(unit[field]) || unit[field].length === 0) {
|
||||||
|
errors.push(`${base}.${field}: unit with status ${safeQuote(unit.status)} requires entries`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStateInvariants(unit, base, errors) {
|
||||||
|
const emptyEvidence = () => {
|
||||||
|
requireEmptyArray(unit, "reviewed_paths", base, errors);
|
||||||
|
requireEmptyArray(unit, "local_checks", base, errors);
|
||||||
|
};
|
||||||
|
const requireOwner = () => {
|
||||||
|
if (!isSafeAgentId(unit.agent_id)) errors.push(`${base}.agent_id: unit with status ${safeQuote(unit.status)} requires a canonical lowercase agent ID`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (unit.status !== "candidate") requireEmptyArray(unit, "result_fingerprints", base, errors);
|
||||||
|
|
||||||
|
switch (unit.status) {
|
||||||
|
case "planned":
|
||||||
|
if (unit.agent_id !== null) errors.push(`${base}.agent_id: planned unit must be unassigned`);
|
||||||
|
emptyEvidence();
|
||||||
|
requireEmptyArray(unit, "unresolved", base, errors);
|
||||||
|
break;
|
||||||
|
case "not_applicable":
|
||||||
|
case "out_of_scope":
|
||||||
|
case "deferred":
|
||||||
|
if (unit.agent_id !== null) errors.push(`${base}.agent_id: unit with status ${safeQuote(unit.status)} must be unassigned`);
|
||||||
|
emptyEvidence();
|
||||||
|
requireNonemptyArray(unit, "unresolved", base, errors);
|
||||||
|
break;
|
||||||
|
case "in_progress":
|
||||||
|
requireOwner();
|
||||||
|
emptyEvidence();
|
||||||
|
requireEmptyArray(unit, "unresolved", base, errors);
|
||||||
|
break;
|
||||||
|
case "blocked":
|
||||||
|
requireOwner();
|
||||||
|
requireNonemptyArray(unit, "reviewed_paths", base, errors);
|
||||||
|
requireNonemptyArray(unit, "local_checks", base, errors);
|
||||||
|
requireNonemptyArray(unit, "unresolved", base, errors);
|
||||||
|
break;
|
||||||
|
case "covered":
|
||||||
|
requireOwner();
|
||||||
|
requireNonemptyArray(unit, "reviewed_paths", base, errors);
|
||||||
|
requireNonemptyArray(unit, "local_checks", base, errors);
|
||||||
|
requireEmptyArray(unit, "unresolved", base, errors);
|
||||||
|
break;
|
||||||
|
case "candidate":
|
||||||
|
requireOwner();
|
||||||
|
requireNonemptyArray(unit, "reviewed_paths", base, errors);
|
||||||
|
requireNonemptyArray(unit, "local_checks", base, errors);
|
||||||
|
requireNonemptyArray(unit, "result_fingerprints", base, errors);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAttempts(value, unit, base, errors) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push(`${base}.attempts: expected array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.length > MAX_LIST_ITEMS) errors.push(`${base}.attempts: exceeds ${MAX_LIST_ITEMS} entries`);
|
||||||
|
|
||||||
|
const priorOwners = new Set();
|
||||||
|
const priorArtifacts = new Set();
|
||||||
|
let previousWave = 0;
|
||||||
|
value.slice(0, MAX_LIST_ITEMS).forEach((attempt, index) => {
|
||||||
|
const attemptBase = `${base}.attempts[${index}]`;
|
||||||
|
if (!isObject(attempt)) {
|
||||||
|
errors.push(`${attemptBase}: expected object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const field of ATTEMPT_FIELDS) {
|
||||||
|
if (!hasOwn(attempt, field)) errors.push(`${attemptBase}: missing required field ${safeQuote(field)}`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(attempt.wave) || attempt.wave < 1) {
|
||||||
|
errors.push(`${attemptBase}.wave: expected a positive integer`);
|
||||||
|
} else {
|
||||||
|
if (attempt.wave <= previousWave) errors.push(`${attemptBase}.wave: archived attempt waves must be strictly increasing`);
|
||||||
|
if (Number.isInteger(unit.wave) && attempt.wave >= unit.wave) {
|
||||||
|
errors.push(`${attemptBase}.wave: archived attempt wave must precede current wave ${safeQuote(unit.wave)}`);
|
||||||
|
}
|
||||||
|
previousWave = attempt.wave;
|
||||||
|
}
|
||||||
|
if (!ATTEMPT_STATUSES.has(attempt.status)) {
|
||||||
|
errors.push(`${attemptBase}.status: expected "covered", "candidate", or "blocked"`);
|
||||||
|
}
|
||||||
|
let hasFreshOwner = false;
|
||||||
|
if (!isSafeAgentId(attempt.agent_id)) {
|
||||||
|
errors.push(`${attemptBase}.agent_id: archived attempt requires a canonical lowercase agent ID`);
|
||||||
|
} else if (priorOwners.has(attempt.agent_id)) {
|
||||||
|
errors.push(`${attemptBase}.agent_id: assignment owner must be fresh for each attempt`);
|
||||||
|
} else {
|
||||||
|
hasFreshOwner = true;
|
||||||
|
}
|
||||||
|
validateStringArray(attempt.reviewed_paths, `${attemptBase}.reviewed_paths`, errors, { pathValue: true });
|
||||||
|
validateChecks(attempt.local_checks, `${attemptBase}.local_checks`, errors);
|
||||||
|
validateReviewedPathOwnership(attempt, attemptBase, errors);
|
||||||
|
validateStringArray(attempt.result_fingerprints, `${attemptBase}.result_fingerprints`, errors, { fingerprint: true });
|
||||||
|
validateStringArray(attempt.unresolved, `${attemptBase}.unresolved`, errors);
|
||||||
|
if (!isVisibleText(attempt.reassignment_reason)) errors.push(`${attemptBase}.reassignment_reason: invalid text`);
|
||||||
|
validateStateInvariants(attempt, attemptBase, errors);
|
||||||
|
|
||||||
|
if (Array.isArray(attempt.local_checks)) {
|
||||||
|
attempt.local_checks.forEach((check, checkIndex) => {
|
||||||
|
if (!isObject(check)) return;
|
||||||
|
if (priorOwners.has(check.agent_id)) {
|
||||||
|
errors.push(`${attemptBase}.local_checks[${checkIndex}].agent_id: prior assignment owner evidence must remain in its earlier attempt`);
|
||||||
|
}
|
||||||
|
if (typeof check.artifact === "string" && priorArtifacts.has(check.artifact)) {
|
||||||
|
errors.push(`${attemptBase}.local_checks[${checkIndex}].artifact: artifact from an earlier attempt cannot be reused`);
|
||||||
|
}
|
||||||
|
if (check.method === "local" && typeof check.artifact === "string") priorArtifacts.add(check.artifact);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hasFreshOwner) priorOwners.add(attempt.agent_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSafeAgentId(unit.agent_id) && priorOwners.has(unit.agent_id)) {
|
||||||
|
errors.push(`${base}.agent_id: current assignment owner must be fresh after reassignment`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(unit.local_checks)) {
|
||||||
|
unit.local_checks.forEach((check, index) => {
|
||||||
|
if (!isObject(check)) return;
|
||||||
|
if (priorOwners.has(check.agent_id)) {
|
||||||
|
errors.push(`${base}.local_checks[${index}].agent_id: prior assignment owner evidence must remain in its archived attempt`);
|
||||||
|
}
|
||||||
|
if (typeof check.artifact === "string" && priorArtifacts.has(check.artifact)) {
|
||||||
|
errors.push(`${base}.local_checks[${index}].artifact: artifact from an archived attempt cannot be reused`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUnitErrors(unit, index) {
|
||||||
|
const errors = createErrorList();
|
||||||
|
const base = `$[${index}]`;
|
||||||
|
if (!isObject(unit)) return [`${base}: expected object`];
|
||||||
|
|
||||||
|
for (const field of REQUIRED_FIELDS) {
|
||||||
|
if (!hasOwn(unit, field)) errors.push(`${base}: missing required field ${safeQuote(field)}`);
|
||||||
|
}
|
||||||
|
for (const field of ["surface", "boundary", "subsystem", "attack_class"]) {
|
||||||
|
if (!isVisibleText(unit[field])) errors.push(`${base}.${field}: invalid text`);
|
||||||
|
}
|
||||||
|
if (hasOwn(unit, "lifecycle") && !isVisibleText(unit.lifecycle)) errors.push(`${base}.lifecycle: invalid text`);
|
||||||
|
|
||||||
|
let expectedId = null;
|
||||||
|
if (!isObject(unit.canonical_refs)) {
|
||||||
|
errors.push(`${base}.canonical_refs: expected object`);
|
||||||
|
} else {
|
||||||
|
const expectedFields = hasOwn(unit.canonical_refs, "lifecycle") ? [...REF_FIELDS, "lifecycle"] : REF_FIELDS;
|
||||||
|
for (const field of expectedFields) {
|
||||||
|
if (!hasOwn(unit.canonical_refs, field)) {
|
||||||
|
errors.push(`${base}.canonical_refs: missing required field ${safeQuote(field)}`);
|
||||||
|
} else if (!isCanonicalRef(unit.canonical_refs[field])) {
|
||||||
|
errors.push(`${base}.canonical_refs.${field}: invalid canonical reference`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(unit.canonical_refs).some((field) => !expectedFields.includes(field))) {
|
||||||
|
errors.push(`${base}.canonical_refs: contains unexpected fields`);
|
||||||
|
}
|
||||||
|
if (hasOwn(unit, "lifecycle") !== hasOwn(unit.canonical_refs, "lifecycle")) {
|
||||||
|
errors.push(`${base}: lifecycle and canonical_refs.lifecycle must appear together`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
expectedId = canonicalCoverageId(unit.canonical_refs);
|
||||||
|
} catch {
|
||||||
|
// The specific reference errors above are more useful.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isVisibleText(unit.coverage_id, 65536)) {
|
||||||
|
errors.push(`${base}.coverage_id: invalid text`);
|
||||||
|
} else if (expectedId !== null && unit.coverage_id !== expectedId) {
|
||||||
|
errors.push(`${base}.coverage_id: expected canonical ID ${safeQuote(expectedId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateStringArray(unit.starting_paths, `${base}.starting_paths`, errors, { allowEmpty: false, pathValue: true });
|
||||||
|
if (unit.ordinary_attack_class_block !== null && !isVisibleText(unit.ordinary_attack_class_block)) {
|
||||||
|
errors.push(`${base}.ordinary_attack_class_block: expected null or non-empty text`);
|
||||||
|
}
|
||||||
|
validateStringArray(unit.selected_companion_blocks, `${base}.selected_companion_blocks`, errors);
|
||||||
|
validateExcludedBlocks(unit.excluded_blocks, `${base}.excluded_blocks`, errors);
|
||||||
|
if (Array.isArray(unit.selected_companion_blocks) && Array.isArray(unit.excluded_blocks)) {
|
||||||
|
const selected = new Set(unit.selected_companion_blocks);
|
||||||
|
unit.excluded_blocks.forEach((entry, blockIndex) => {
|
||||||
|
if (isObject(entry) && selected.has(entry.block)) {
|
||||||
|
errors.push(`${base}.excluded_blocks[${blockIndex}].block: block is also selected`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PRIOR_STATUSES.has(unit.prior_status)) errors.push(`${base}.prior_status: invalid value ${safeQuote(unit.prior_status)}`);
|
||||||
|
if (!STATUSES.has(unit.status)) errors.push(`${base}.status: invalid value ${safeQuote(unit.status)}`);
|
||||||
|
if (!Number.isInteger(unit.wave) || unit.wave < 1) errors.push(`${base}.wave: expected a positive integer`);
|
||||||
|
if (unit.agent_id !== null && !isSafeAgentId(unit.agent_id)) errors.push(`${base}.agent_id: expected null or a safe agent ID`);
|
||||||
|
|
||||||
|
validateAttempts(unit.attempts, unit, base, errors);
|
||||||
|
validateStringArray(unit.reviewed_paths, `${base}.reviewed_paths`, errors, { pathValue: true });
|
||||||
|
validateChecks(unit.local_checks, `${base}.local_checks`, errors);
|
||||||
|
validateReviewedPathOwnership(unit, base, errors);
|
||||||
|
validateStringArray(unit.result_fingerprints, `${base}.result_fingerprints`, errors, { fingerprint: true });
|
||||||
|
validateStringArray(unit.unresolved, `${base}.unresolved`, errors);
|
||||||
|
|
||||||
|
validateStateInvariants(unit, base, errors);
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileWithinLimit(file) {
|
||||||
|
const noFollow = fs.constants.O_NOFOLLOW;
|
||||||
|
const nonBlock = fs.constants.O_NONBLOCK;
|
||||||
|
if (!Number.isInteger(noFollow) || noFollow === 0 || !Number.isInteger(nonBlock) || nonBlock === 0) {
|
||||||
|
// Node exposes no race-safe fallback on these platforms, so reject all inputs.
|
||||||
|
throw new SafeInputError("OS no-follow and nonblocking input protection is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
let descriptor;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && (error.code === "ELOOP" || error.code === "EMLINK")) throw new SafeInputError("input must not be a symlink");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = fs.fstatSync(descriptor);
|
||||||
|
if (!stat.isFile()) throw new SafeInputError("input must be a regular file");
|
||||||
|
if (stat.size > MAX_INPUT_BYTES) throw new SafeInputError(`input exceeds ${MAX_INPUT_BYTES} byte limit`);
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||||
|
let bytesRead = 0;
|
||||||
|
while (true) {
|
||||||
|
const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
||||||
|
if (count === 0) break;
|
||||||
|
bytesRead += count;
|
||||||
|
if (bytesRead > MAX_INPUT_BYTES) throw new SafeInputError(`input exceeds ${MAX_INPUT_BYTES} byte limit`);
|
||||||
|
chunks.push(Buffer.from(buffer.subarray(0, count)));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return UTF8_DECODER.decode(Buffer.concat(chunks, bytesRead));
|
||||||
|
} catch {
|
||||||
|
throw new SafeInputError("input is not valid UTF-8");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDocument(ledger) {
|
||||||
|
const errors = createErrorList();
|
||||||
|
if (!Array.isArray(ledger)) {
|
||||||
|
errors.push("$: expected a top-level array");
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
if (ledger.length > MAX_UNITS) {
|
||||||
|
errors.push(`$: exceeds ${MAX_UNITS} coverage units`);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.push(...preflightDocument(ledger));
|
||||||
|
if (errors.length > 0) return errors;
|
||||||
|
|
||||||
|
const ids = new Map();
|
||||||
|
const semantics = new Map();
|
||||||
|
let previousId = null;
|
||||||
|
for (let index = 0; index < ledger.length && errors.length < LIMITS.validationErrors; index++) {
|
||||||
|
const unit = ledger[index];
|
||||||
|
errors.push(...collectUnitErrors(unit, index));
|
||||||
|
if (errors.length >= LIMITS.validationErrors) break;
|
||||||
|
if (!isObject(unit) || typeof unit.coverage_id !== "string") continue;
|
||||||
|
|
||||||
|
const key = hasValidSemanticFields(unit) ? semanticKey(unit) : null;
|
||||||
|
if (ids.has(unit.coverage_id)) {
|
||||||
|
const previous = ids.get(unit.coverage_id);
|
||||||
|
const qualifier = key !== null && previous.key !== null && previous.key !== key
|
||||||
|
? "canonical identity collision with different semantic fields"
|
||||||
|
: "duplicate coverage ID";
|
||||||
|
errors.push(`$[${index}].coverage_id: ${qualifier} at $[${previous.index}]`);
|
||||||
|
} else {
|
||||||
|
ids.set(unit.coverage_id, { index, key });
|
||||||
|
}
|
||||||
|
if (key !== null && semantics.has(key) && semantics.get(key).id !== unit.coverage_id) {
|
||||||
|
const previous = semantics.get(key);
|
||||||
|
errors.push(`$[${index}].canonical_refs: semantic tuple already uses coverage ID ${safeQuote(previous.id)} at $[${previous.index}]`);
|
||||||
|
} else if (key !== null) {
|
||||||
|
semantics.set(key, { id: unit.coverage_id, index });
|
||||||
|
}
|
||||||
|
if (previousId !== null && previousId > unit.coverage_id) {
|
||||||
|
errors.push(`$[${index}].coverage_id: units must be sorted lexicographically`);
|
||||||
|
}
|
||||||
|
previousId = unit.coverage_id;
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(file) {
|
||||||
|
if (!file) {
|
||||||
|
console.error("Usage: node validate-coverage-ledger.cjs <path-to-coverage-ledger.json>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents;
|
||||||
|
try {
|
||||||
|
contents = readFileWithinLimit(file);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof SafeInputError ? error.message : "input could not be opened or read safely";
|
||||||
|
console.error(`Failed to read coverage ledger: ${reason}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ledger;
|
||||||
|
try {
|
||||||
|
preflightJsonText(contents);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof JsonStructureError ? error.message : "invalid JSON structure";
|
||||||
|
console.error(`Failed to parse coverage ledger: ${reason}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ledger = JSON.parse(contents);
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to parse coverage ledger: invalid JSON syntax");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors;
|
||||||
|
try {
|
||||||
|
errors = validateDocument(ledger);
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to validate coverage ledger: unexpected validation error");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const message of errors) console.error("ERROR:", message);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const cap = errors.length === LIMITS.validationErrors ? `; output capped at ${LIMITS.validationErrors}` : "";
|
||||||
|
console.error(`FAIL: ${errors.length} validation error(s)${cap}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
console.log(`PASS: ${ledger.length} coverage units valid`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
LIMITS,
|
||||||
|
PATH_FORBIDDEN_CHARACTER,
|
||||||
|
UNSAFE_DIAGNOSTIC_CHARACTER,
|
||||||
|
VISIBLE_CONTENT,
|
||||||
|
WINDOWS_RESERVED_COMPONENT,
|
||||||
|
canonicalCoverageId,
|
||||||
|
encodeCanonicalRef,
|
||||||
|
hasVisibleProse,
|
||||||
|
isSafeAgentId,
|
||||||
|
isSafeRelativePath,
|
||||||
|
preflightJsonText,
|
||||||
|
readFileWithinLimit,
|
||||||
|
safeQuote,
|
||||||
|
validateDocument,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) process.exit(run(process.argv[2]));
|
||||||
740
.agents/skills/security-audit/validate-coverage-ledger.test.cjs
Normal file
740
.agents/skills/security-audit/validate-coverage-ledger.test.cjs
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
const test = require("node:test");
|
||||||
|
const {
|
||||||
|
LIMITS,
|
||||||
|
canonicalCoverageId,
|
||||||
|
encodeCanonicalRef,
|
||||||
|
isSafeAgentId,
|
||||||
|
isSafeRelativePath,
|
||||||
|
preflightJsonText,
|
||||||
|
validateDocument,
|
||||||
|
} = require("./validate-coverage-ledger.cjs");
|
||||||
|
|
||||||
|
const validatorPath = path.join(__dirname, "validate-coverage-ledger.cjs");
|
||||||
|
const CLI_TIMEOUT_MS = 5000;
|
||||||
|
const HOSTILE_CLI_TIMEOUT_MS = 15000;
|
||||||
|
const HAS_SAFE_INPUT_OPEN = Number.isInteger(fs.constants.O_NOFOLLOW) &&
|
||||||
|
fs.constants.O_NOFOLLOW !== 0 &&
|
||||||
|
Number.isInteger(fs.constants.O_NONBLOCK) &&
|
||||||
|
fs.constants.O_NONBLOCK !== 0;
|
||||||
|
|
||||||
|
function unit(overrides = {}) {
|
||||||
|
const canonicalRefs = overrides.canonical_refs || {
|
||||||
|
surface: "src/router.ts#POST /users/:id",
|
||||||
|
boundary: "src/authz.ts#requireOwner",
|
||||||
|
subsystem: "packages/api",
|
||||||
|
attack_class: "ATTACK-CLASSES.md#Access control",
|
||||||
|
};
|
||||||
|
const value = {
|
||||||
|
coverage_id: canonicalCoverageId(canonicalRefs),
|
||||||
|
canonical_refs: canonicalRefs,
|
||||||
|
surface: "Update-user route",
|
||||||
|
boundary: "Object ownership",
|
||||||
|
subsystem: "API",
|
||||||
|
attack_class: "Access control",
|
||||||
|
starting_paths: ["src/router.ts", "src/authz.ts"],
|
||||||
|
ordinary_attack_class_block: "ATTACK-CLASSES.md#Access control",
|
||||||
|
selected_companion_blocks: [],
|
||||||
|
excluded_blocks: [{ block: "WEB-PROTOCOL-AND-AUTH.md#Cache behavior", reason: "The route is not cached." }],
|
||||||
|
prior_status: "new",
|
||||||
|
attempts: [],
|
||||||
|
wave: 1,
|
||||||
|
status: "planned",
|
||||||
|
agent_id: null,
|
||||||
|
reviewed_paths: [],
|
||||||
|
local_checks: [],
|
||||||
|
result_fingerprints: [],
|
||||||
|
unresolved: [],
|
||||||
|
};
|
||||||
|
return Object.assign(value, overrides, { canonical_refs: canonicalRefs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorsFor(value) {
|
||||||
|
return validateDocument(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(contents, options = {}) {
|
||||||
|
const { nodeArgs = [], timeout = CLI_TIMEOUT_MS } = options;
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-"));
|
||||||
|
const ledgerPath = path.join(directory, "coverage-ledger.json");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(ledgerPath, contents);
|
||||||
|
return spawnSync(process.execPath, [...nodeArgs, validatorPath, ledgerPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliOutput(result) {
|
||||||
|
return `${result.stdout}${result.stderr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_CONTROL_PAYLOAD = "\u001b\u0007\u0085\u202e";
|
||||||
|
const TERMINAL_CONTROL_BYTES = [
|
||||||
|
Buffer.from([0x1b]),
|
||||||
|
Buffer.from([0x07]),
|
||||||
|
Buffer.from("\u0085"),
|
||||||
|
Buffer.from("\u202e"),
|
||||||
|
];
|
||||||
|
|
||||||
|
function assertNoInjectedControlBytes(output) {
|
||||||
|
const bytes = Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8");
|
||||||
|
for (const marker of TERMINAL_CONTROL_BYTES) {
|
||||||
|
assert.equal(bytes.indexOf(marker), -1, `found raw control bytes ${marker.toString("hex")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceCheck(agentId = "hunter-1", overrides = {}) {
|
||||||
|
return {
|
||||||
|
agent_id: agentId,
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
invariant: "The route checks object ownership.",
|
||||||
|
method: "source",
|
||||||
|
result: "The owner check applies before the update.",
|
||||||
|
artifact: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function localCheck(agentId = "hunter-1", overrides = {}) {
|
||||||
|
return sourceCheck(agentId, {
|
||||||
|
method: "local",
|
||||||
|
result: "The bounded fixture accepted the other owner's object.",
|
||||||
|
artifact: `agents/${agentId}/artifacts/result.txt`,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function archivedAttempt(overrides = {}) {
|
||||||
|
const value = {
|
||||||
|
wave: 1,
|
||||||
|
status: "blocked",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
result_fingerprints: [],
|
||||||
|
unresolved: ["The deployed policy is unavailable."],
|
||||||
|
reassignment_reason: "The critic found an unchecked parallel path.",
|
||||||
|
};
|
||||||
|
return Object.assign(value, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("accepts an empty ledger and complete units", () => {
|
||||||
|
assert.deepEqual(errorsFor([]), []);
|
||||||
|
assert.deepEqual(errorsFor([unit()]), []);
|
||||||
|
|
||||||
|
const missingAttempts = unit();
|
||||||
|
delete missingAttempts.attempts;
|
||||||
|
assert(errorsFor([missingAttempts]).some((error) => error.includes('missing required field "attempts"')));
|
||||||
|
|
||||||
|
const covered = unit({
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
});
|
||||||
|
assert.deepEqual(errorsFor([covered]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a complete ledger through the CLI", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const result = runCli(JSON.stringify([unit()]));
|
||||||
|
assert.equal(result.status, 0, cliOutput(result));
|
||||||
|
assert.match(result.stdout, /PASS: 1 coverage units valid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text preflight ignores structural characters and escapes inside strings", () => {
|
||||||
|
const value = unit({
|
||||||
|
surface: "Route \\ slash [list] {object}, colon: quoted \"value\"",
|
||||||
|
excluded_blocks: [{
|
||||||
|
block: "COMPANION.md#Literal [brackets] {braces}",
|
||||||
|
reason: "The text contains a backslash \\ before an escaped \"quote\".",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const contents = JSON.stringify([value]);
|
||||||
|
assert.doesNotThrow(() => preflightJsonText(contents));
|
||||||
|
assert.deepEqual(JSON.parse(contents), [value]);
|
||||||
|
if (HAS_SAFE_INPUT_OPEN) {
|
||||||
|
const result = runCli(contents);
|
||||||
|
assert.equal(result.status, 0, cliOutput(result));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text preflight enforces structural cardinality limits", () => {
|
||||||
|
const depthLimit = LIMITS.nestingDepth;
|
||||||
|
assert.doesNotThrow(() => preflightJsonText(`${"[".repeat(depthLimit)}0${"]".repeat(depthLimit)}`));
|
||||||
|
assert.throws(
|
||||||
|
() => preflightJsonText(`${"[".repeat(depthLimit + 1)}0${"]".repeat(depthLimit + 1)}`),
|
||||||
|
/exceeds nesting depth limit 64/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooManyUnits = `[${"null,".repeat(LIMITS.units)}null]`;
|
||||||
|
assert.throws(() => preflightJsonText(tooManyUnits), /exceeds 10000 top-level unit limit/);
|
||||||
|
|
||||||
|
const tooManyItems = `[[${"null,".repeat(LIMITS.collectionItems)}null]]`;
|
||||||
|
assert.throws(() => preflightJsonText(tooManyItems), /exceeds 1000 item array limit/);
|
||||||
|
|
||||||
|
const objectFields = Array.from(
|
||||||
|
{ length: LIMITS.objectFields + 1 },
|
||||||
|
(_, index) => `"field${index}":null`,
|
||||||
|
).join(",");
|
||||||
|
assert.throws(() => preflightJsonText(`[{${objectFields}}]`), /exceeds 1000 field object limit/);
|
||||||
|
|
||||||
|
const fullArray = `[${"null,".repeat(LIMITS.collectionItems - 1)}null]`;
|
||||||
|
const arraysNeeded = Math.floor(LIMITS.preflightValues / (LIMITS.collectionItems + 1)) + 1;
|
||||||
|
const tooManyValues = `[${Array.from({ length: arraysNeeded }, () => fullArray).join(",")}]`;
|
||||||
|
assert.throws(() => preflightJsonText(tooManyValues), /exceeds 500000 total value limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text preflight rejects malformed structural truncation cleanly", () => {
|
||||||
|
assert.throws(() => preflightJsonText("["), /truncated JSON structure/);
|
||||||
|
assert.throws(() => preflightJsonText("[\"unterminated"), /unterminated JSON string/);
|
||||||
|
assert.throws(() => preflightJsonText("[{\"field\":1]"), /mismatched JSON containers/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives collision-free canonical IDs from exact UTF-8 references", () => {
|
||||||
|
assert.equal(encodeCanonicalRef("route:POST /users"), "route%3APOST%20%2Fusers");
|
||||||
|
assert.notEqual(encodeCanonicalRef("route name"), encodeCanonicalRef("route-name"));
|
||||||
|
assert.equal(
|
||||||
|
canonicalCoverageId({ surface: "a", boundary: "b", subsystem: "c", attack_class: "d", lifecycle: "retry" }),
|
||||||
|
"a::b::c::d::retry",
|
||||||
|
);
|
||||||
|
assert.throws(() => encodeCanonicalRef("e\u0301"), /invalid canonical reference/);
|
||||||
|
assert.throws(() => encodeCanonicalRef("bad\u0000ref"), /invalid canonical reference/);
|
||||||
|
assert.throws(() => encodeCanonicalRef("hidden\u200bref"), /invalid canonical reference/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects noncanonical, duplicate, and colliding IDs", () => {
|
||||||
|
const wrong = unit({ coverage_id: "display-label-slug" });
|
||||||
|
assert(errorsFor([wrong]).some((error) => error.includes("expected canonical ID")));
|
||||||
|
|
||||||
|
const duplicate = unit();
|
||||||
|
assert(errorsFor([duplicate, unit()]).some((error) => error.includes("duplicate coverage ID")));
|
||||||
|
|
||||||
|
const collision = unit();
|
||||||
|
const differentMeaning = unit({ surface: "Delete-user route" });
|
||||||
|
assert(errorsFor([collision, differentMeaning]).some((error) => error.includes("canonical identity collision")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires canonical references to be own properties", () => {
|
||||||
|
const inherited = Object.create(unit().canonical_refs);
|
||||||
|
const value = unit();
|
||||||
|
value.canonical_refs = inherited;
|
||||||
|
assert(errorsFor([value]).some((error) => error.includes("missing required field")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects aliases for one semantic tuple", () => {
|
||||||
|
const first = unit();
|
||||||
|
const refs = { ...first.canonical_refs, surface: "src/alias.ts#updateUser" };
|
||||||
|
const alias = unit({ canonical_refs: refs });
|
||||||
|
const ledger = [first, alias].sort((left, right) => left.coverage_id.localeCompare(right.coverage_id));
|
||||||
|
assert(errorsFor(ledger).some((error) => error.includes("semantic tuple already uses coverage ID")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires lexicographic order", () => {
|
||||||
|
const secondRefs = {
|
||||||
|
surface: "zzz",
|
||||||
|
boundary: "src/authz.ts#requireOwner",
|
||||||
|
subsystem: "packages/api",
|
||||||
|
attack_class: "ATTACK-CLASSES.md#Access control",
|
||||||
|
};
|
||||||
|
assert(errorsFor([unit({ canonical_refs: secondRefs }), unit()])
|
||||||
|
.some((error) => error.includes("sorted lexicographically")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates assignment block maps", () => {
|
||||||
|
const overlap = unit({
|
||||||
|
selected_companion_blocks: ["AI-AND-LLM.md#Tool calls"],
|
||||||
|
excluded_blocks: [{ block: "AI-AND-LLM.md#Tool calls", reason: "Claimed irrelevant." }],
|
||||||
|
});
|
||||||
|
assert(errorsFor([overlap]).some((error) => error.includes("also selected")));
|
||||||
|
|
||||||
|
const noReason = unit({ excluded_blocks: [{ block: "AI-AND-LLM.md#Tool calls", reason: "" }] });
|
||||||
|
assert(errorsFor([noReason]).some((error) => error.includes("reason")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires owned artifacts for local checks and null artifacts for source checks", () => {
|
||||||
|
const local = unit({
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [localCheck()],
|
||||||
|
});
|
||||||
|
assert.deepEqual(errorsFor([local]), []);
|
||||||
|
|
||||||
|
const independentlyVerified = unit({
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts", "src/authz.ts"],
|
||||||
|
local_checks: [sourceCheck(), localCheck("verifier-1", { reviewed_paths: ["src/authz.ts"] })],
|
||||||
|
});
|
||||||
|
assert.deepEqual(errorsFor([independentlyVerified]), []);
|
||||||
|
|
||||||
|
const unownedPath = unit({
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts", "src/authz.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
});
|
||||||
|
assert(errorsFor([unownedPath]).some((error) => error.includes("has no check owner")));
|
||||||
|
|
||||||
|
for (const [checkAgentId, artifact] of [
|
||||||
|
[null, "agents/hunter-1/artifacts/result.txt"],
|
||||||
|
["hunter-1", null],
|
||||||
|
["hunter-1", "result.txt"],
|
||||||
|
["hunter-1", "agents/hunter-2/artifacts/result.txt"],
|
||||||
|
["../hunter", "agents/../hunter/artifacts/result.txt"],
|
||||||
|
]) {
|
||||||
|
const value = unit({
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [localCheck(checkAgentId, { artifact })],
|
||||||
|
});
|
||||||
|
assert.notEqual(errorsFor([value]).length, 0, `${checkAgentId}: ${artifact}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unownedSource = unit({
|
||||||
|
status: "blocked",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
unresolved: ["The boundary behavior is not source-visible."],
|
||||||
|
});
|
||||||
|
assert(errorsFor([unownedSource]).some((error) => error.includes("unit with status \"blocked\" requires a canonical lowercase agent ID")));
|
||||||
|
|
||||||
|
const sourceWithArtifact = unit({
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck("hunter-1", { artifact: "agents/hunter-1/artifacts/source.txt" })],
|
||||||
|
});
|
||||||
|
assert(errorsFor([sourceWithArtifact]).some((error) => error.includes("source-only check must use null")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires canonical lowercase filesystem-safe agent IDs", () => {
|
||||||
|
for (const value of ["hunter-1", "verifier_2", "a0"]) assert.equal(isSafeAgentId(value), true, value);
|
||||||
|
for (const value of ["Hunter-1", "hunter.1", "hunter-1.", "hunter ", "con", "prn", "aux", "nul", "com1", "lpt9", "../hunter"]) {
|
||||||
|
assert.equal(isSafeAgentId(value), false, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseAlias = unit({ status: "in_progress", agent_id: "Hunter-1" });
|
||||||
|
assert(errorsFor([caseAlias]).some((error) => error.includes("canonical lowercase agent ID")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enforces state evidence", () => {
|
||||||
|
assert(errorsFor([unit({ status: "in_progress" })]).some((error) => error.includes("unit with status \"in_progress\" requires")));
|
||||||
|
assert(errorsFor([unit({ status: "blocked" })]).some((error) => error.includes("unresolved")));
|
||||||
|
assert(errorsFor([unit({ status: "candidate" })]).some((error) => error.includes("reviewed_paths")));
|
||||||
|
|
||||||
|
assert.deepEqual(errorsFor([unit({ status: "in_progress", agent_id: "hunter-1" })]), []);
|
||||||
|
const inProgressEvidence = unit({
|
||||||
|
status: "in_progress",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
});
|
||||||
|
assert(errorsFor([inProgressEvidence]).some((error) => error.includes("must keep this array empty")));
|
||||||
|
|
||||||
|
const assignedPlanned = unit({
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
});
|
||||||
|
assert(errorsFor([assignedPlanned]).some((error) => error.includes("planned unit must be unassigned")));
|
||||||
|
|
||||||
|
const candidate = unit({
|
||||||
|
status: "candidate",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck("hunter-1", { invariant: "Ownership is required.", result: "No check exists." })],
|
||||||
|
result_fingerprints: ["src-router-missing-owner-check"],
|
||||||
|
unresolved: ["validation_budget_exhausted"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(errorsFor([candidate]), []);
|
||||||
|
|
||||||
|
const blocked = unit({
|
||||||
|
status: "blocked",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
unresolved: ["The deployed policy is unavailable."],
|
||||||
|
});
|
||||||
|
assert.deepEqual(errorsFor([blocked]), []);
|
||||||
|
const blockedFingerprint = { ...blocked, result_fingerprints: ["forbidden-fingerprint"] };
|
||||||
|
assert(errorsFor([blockedFingerprint]).some((error) => error.includes("result_fingerprints")));
|
||||||
|
|
||||||
|
for (const status of ["not_applicable", "out_of_scope", "deferred"]) {
|
||||||
|
assert.deepEqual(errorsFor([unit({ status, unresolved: ["Reason recorded."] })]), []);
|
||||||
|
const invalid = unit({
|
||||||
|
status,
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
result_fingerprints: ["forbidden-fingerprint"],
|
||||||
|
unresolved: ["Reason recorded."],
|
||||||
|
});
|
||||||
|
const errors = errorsFor([invalid]);
|
||||||
|
assert(errors.some((error) => error.includes("must be unassigned")), status);
|
||||||
|
assert(errors.some((error) => error.includes("reviewed_paths")), status);
|
||||||
|
assert(errors.some((error) => error.includes("result_fingerprints")), status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coveredFingerprint = unit({
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
result_fingerprints: ["forbidden-fingerprint"],
|
||||||
|
});
|
||||||
|
assert(errorsFor([coveredFingerprint]).some((error) => error.includes("result_fingerprints")));
|
||||||
|
|
||||||
|
const coveredUnresolved = unit({
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
unresolved: ["Unexpected unresolved claim."],
|
||||||
|
});
|
||||||
|
assert(errorsFor([coveredUnresolved]).some((error) => error.includes("unresolved")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("archives prior evidence when a critic assigns a fresh owner", () => {
|
||||||
|
const reassigned = unit({
|
||||||
|
attempts: [archivedAttempt()],
|
||||||
|
wave: 2,
|
||||||
|
status: "in_progress",
|
||||||
|
agent_id: "hunter-2",
|
||||||
|
});
|
||||||
|
assert.deepEqual(errorsFor([reassigned]), []);
|
||||||
|
|
||||||
|
const finalClosure = unit({
|
||||||
|
attempts: [archivedAttempt()],
|
||||||
|
wave: 2,
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-2",
|
||||||
|
reviewed_paths: ["src/authz.ts"],
|
||||||
|
local_checks: [sourceCheck("hunter-2", { reviewed_paths: ["src/authz.ts"] })],
|
||||||
|
});
|
||||||
|
assert.deepEqual(errorsFor([finalClosure]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves candidate provenance when reassignment must be deferred", () => {
|
||||||
|
const candidateAttempt = archivedAttempt({
|
||||||
|
status: "candidate",
|
||||||
|
result_fingerprints: ["src-router-missing-owner-check"],
|
||||||
|
unresolved: ["validation_budget_exhausted"],
|
||||||
|
});
|
||||||
|
const deferred = unit({
|
||||||
|
attempts: [candidateAttempt],
|
||||||
|
wave: 2,
|
||||||
|
status: "deferred",
|
||||||
|
unresolved: ["quick_profile_final_critic"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(errorsFor([deferred]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects reassignment owner reuse and evidence mixing", () => {
|
||||||
|
const reusedOwner = unit({
|
||||||
|
attempts: [archivedAttempt()],
|
||||||
|
wave: 2,
|
||||||
|
status: "in_progress",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
});
|
||||||
|
assert(errorsFor([reusedOwner]).some((error) => error.includes("current assignment owner must be fresh")));
|
||||||
|
|
||||||
|
const mixedEvidence = unit({
|
||||||
|
attempts: [archivedAttempt({ local_checks: [localCheck()] })],
|
||||||
|
wave: 2,
|
||||||
|
status: "covered",
|
||||||
|
agent_id: "hunter-2",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [localCheck()],
|
||||||
|
});
|
||||||
|
const errors = errorsFor([mixedEvidence]);
|
||||||
|
assert(errors.some((error) => error.includes("prior assignment owner evidence must remain")));
|
||||||
|
assert(errors.some((error) => error.includes("artifact from an archived attempt cannot be reused")));
|
||||||
|
|
||||||
|
const mixedHistory = unit({
|
||||||
|
attempts: [
|
||||||
|
archivedAttempt({ local_checks: [localCheck()] }),
|
||||||
|
archivedAttempt({
|
||||||
|
wave: 2,
|
||||||
|
agent_id: "hunter-2",
|
||||||
|
local_checks: [localCheck()],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
wave: 3,
|
||||||
|
status: "in_progress",
|
||||||
|
agent_id: "hunter-3",
|
||||||
|
});
|
||||||
|
const historyErrors = errorsFor([mixedHistory]);
|
||||||
|
assert(historyErrors.some((error) => error.includes("prior assignment owner evidence must remain in its earlier attempt")));
|
||||||
|
assert(historyErrors.some((error) => error.includes("artifact from an earlier attempt cannot be reused")));
|
||||||
|
|
||||||
|
const unordered = unit({
|
||||||
|
attempts: [archivedAttempt(), archivedAttempt({
|
||||||
|
wave: 1,
|
||||||
|
agent_id: "hunter-2",
|
||||||
|
local_checks: [sourceCheck("hunter-2")],
|
||||||
|
})],
|
||||||
|
wave: 3,
|
||||||
|
status: "in_progress",
|
||||||
|
agent_id: "hunter-3",
|
||||||
|
});
|
||||||
|
assert(errorsFor([unordered]).some((error) => error.includes("strictly increasing")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unsafe paths and malformed fingerprints", () => {
|
||||||
|
for (const value of [
|
||||||
|
"/etc/passwd",
|
||||||
|
"../src/file.js",
|
||||||
|
"src/../file.js",
|
||||||
|
"src/con.txt",
|
||||||
|
"src/PRN",
|
||||||
|
"src/AUX.c",
|
||||||
|
"src/NUL",
|
||||||
|
"src/CLOCK$.txt",
|
||||||
|
"src/conin$.txt",
|
||||||
|
"src/conout$",
|
||||||
|
"src/COM1.log",
|
||||||
|
"src/lpt9",
|
||||||
|
"src/COM\u00b9.log",
|
||||||
|
"src/COM\u00b2.log",
|
||||||
|
"src/COM\u00b3.log",
|
||||||
|
"src/lpt\u00b9",
|
||||||
|
"src/lpt\u00b2",
|
||||||
|
"src/lpt\u00b3",
|
||||||
|
"src/file.js.",
|
||||||
|
"C:/src/file.js",
|
||||||
|
"src/file\n.js",
|
||||||
|
"src/file\u0085.js",
|
||||||
|
"src/file\u2028.js",
|
||||||
|
"src/file\u200b.js",
|
||||||
|
"src/file\u034f.js",
|
||||||
|
"src/file\ufe0f.js",
|
||||||
|
]) {
|
||||||
|
assert.equal(isSafeRelativePath(value), false, value);
|
||||||
|
}
|
||||||
|
assert.equal(isSafeRelativePath("src/handler.js"), true);
|
||||||
|
assert.equal(isSafeRelativePath("src/caf\u00e9/handler.js"), true);
|
||||||
|
assert(errorsFor([unit({ starting_paths: ["../src/router.ts"] })]).some((error) => error.includes("repository-relative path")));
|
||||||
|
assert(errorsFor([unit({
|
||||||
|
status: "candidate",
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: ["src/router.ts"],
|
||||||
|
local_checks: [sourceCheck("hunter-1", { invariant: "Ownership is required.", result: "No check exists." })],
|
||||||
|
result_fingerprints: ["not stable"],
|
||||||
|
})]).some((error) => error.includes("invalid fingerprint")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects format, default-ignorable, and invalid-scalar prose", () => {
|
||||||
|
for (const invisible of ["\u200b", "\u034f", "\ufe0f", "\ud800"]) {
|
||||||
|
assert(errorsFor([unit({ surface: invisible })]).some((error) => error.includes("surface")), JSON.stringify(invisible));
|
||||||
|
assert(errorsFor([unit({
|
||||||
|
excluded_blocks: [{ block: "ATTACK-CLASSES.md#Access control", reason: invisible }],
|
||||||
|
})]).some((error) => error.includes("reason")), JSON.stringify(invisible));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotes input-derived controls in direct validation errors", () => {
|
||||||
|
const invalidStatus = `invalid-${TERMINAL_CONTROL_PAYLOAD}`;
|
||||||
|
const invalidPath = `src/${TERMINAL_CONTROL_PAYLOAD}.js`;
|
||||||
|
const value = unit({
|
||||||
|
status: invalidStatus,
|
||||||
|
agent_id: "hunter-1",
|
||||||
|
reviewed_paths: [invalidPath],
|
||||||
|
local_checks: [sourceCheck()],
|
||||||
|
result_fingerprints: ["force-state-error"],
|
||||||
|
});
|
||||||
|
const output = errorsFor([value]).join("\n");
|
||||||
|
|
||||||
|
assert.match(output, /\$\[0\]\.status/);
|
||||||
|
assert.match(output, /\$\[0\]\.reviewed_paths/);
|
||||||
|
assert.match(output, /\\u001b/);
|
||||||
|
assert.match(output, /\\u0007/);
|
||||||
|
assert.match(output, /\\u0085/);
|
||||||
|
assert.match(output, /\\u202e/);
|
||||||
|
assertNoInjectedControlBytes(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotes input-derived controls in CLI validation errors", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const value = unit({
|
||||||
|
status: `invalid-${TERMINAL_CONTROL_PAYLOAD}`,
|
||||||
|
result_fingerprints: ["force-state-error"],
|
||||||
|
});
|
||||||
|
const result = runCli(JSON.stringify([value]));
|
||||||
|
|
||||||
|
assert.equal(result.status, 1, cliOutput(result));
|
||||||
|
assert.match(result.stderr, /\$\[0\]\.status/);
|
||||||
|
assert.match(result.stderr, /\\u001b/);
|
||||||
|
assertNoInjectedControlBytes(result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a generic syntax error without parser-supplied controls", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const malformed = Buffer.concat([
|
||||||
|
Buffer.from("["),
|
||||||
|
Buffer.from(TERMINAL_CONTROL_PAYLOAD),
|
||||||
|
Buffer.from("]"),
|
||||||
|
]);
|
||||||
|
const result = runCli(malformed);
|
||||||
|
|
||||||
|
assert.equal(result.status, 1, cliOutput(result));
|
||||||
|
assert.equal(result.stderr, "Failed to parse coverage ledger: invalid JSON syntax\n");
|
||||||
|
assertNoInjectedControlBytes(result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not reflect controls from a failed CLI input path", () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-path-"));
|
||||||
|
const missingPath = path.join(directory, `missing-${TERMINAL_CONTROL_PAYLOAD}.json`);
|
||||||
|
try {
|
||||||
|
const result = spawnSync(process.execPath, [validatorPath, missingPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: CLI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1, cliOutput(result));
|
||||||
|
assert.match(result.stderr, /Failed to read coverage ledger:/);
|
||||||
|
assertNoInjectedControlBytes(result.stderr);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid UTF-8 through the CLI", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const encoded = Buffer.from(JSON.stringify([unit()]));
|
||||||
|
const marker = Buffer.from("Update-user route");
|
||||||
|
const markerOffset = encoded.indexOf(marker);
|
||||||
|
assert.notEqual(markerOffset, -1);
|
||||||
|
const malformed = Buffer.concat([
|
||||||
|
encoded.subarray(0, markerOffset),
|
||||||
|
Buffer.from([0x80]),
|
||||||
|
encoded.subarray(markerOffset + marker.length),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = runCli(malformed);
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /input is not valid UTF-8/);
|
||||||
|
assert.doesNotMatch(output, /TypeError|stack|at validate-coverage-ledger/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a FIFO through the CLI without blocking", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-fifo-"));
|
||||||
|
const fifoPath = path.join(directory, "coverage-ledger.json");
|
||||||
|
try {
|
||||||
|
const created = spawnSync("mkfifo", [fifoPath], { encoding: "utf8", timeout: CLI_TIMEOUT_MS });
|
||||||
|
assert.equal(created.status, 0, cliOutput(created));
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, [validatorPath, fifoPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: CLI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /input must be a regular file/);
|
||||||
|
assert.doesNotMatch(output, /stack|at validate-coverage-ledger/i);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a symlink through the CLI", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-coverage-ledger-symlink-"));
|
||||||
|
const targetPath = path.join(directory, "target.json");
|
||||||
|
const symlinkPath = path.join(directory, "coverage-ledger.json");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(targetPath, JSON.stringify([unit()]));
|
||||||
|
fs.symlinkSync(targetPath, symlinkPath);
|
||||||
|
const result = spawnSync(process.execPath, [validatorPath, symlinkPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: CLI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /input must not be a symlink/);
|
||||||
|
assert.doesNotMatch(output, /stack|at validate-coverage-ledger/i);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects deeply nested input without recursion failure", () => {
|
||||||
|
let nested = 0;
|
||||||
|
for (let depth = 0; depth < 20000; depth++) nested = [nested];
|
||||||
|
assert(errorsFor(nested).some((error) => error.includes("exceeds nesting depth limit 64")));
|
||||||
|
if (!HAS_SAFE_INPUT_OPEN) return;
|
||||||
|
|
||||||
|
const result = runCli(`${"[".repeat(20000)}0${"]".repeat(20000)}`);
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /exceeds nesting depth limit 64/);
|
||||||
|
assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-coverage-ledger/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects multi-megabyte nesting under a constrained Node heap", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const openContainers = "[".repeat(2000000);
|
||||||
|
const cases = [
|
||||||
|
openContainers,
|
||||||
|
`${openContainers}0${"]".repeat(2000000)}`,
|
||||||
|
];
|
||||||
|
for (const contents of cases) {
|
||||||
|
const result = runCli(contents, {
|
||||||
|
nodeArgs: ["--max-old-space-size=64"],
|
||||||
|
timeout: HOSTILE_CLI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /exceeds nesting depth limit 64/);
|
||||||
|
assert.doesNotMatch(output, /heap out of memory|allocation failed|RangeError|Maximum call stack|stack|at validate-coverage-ledger/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caps malformed 10000-unit validation output", () => {
|
||||||
|
assert.equal(errorsFor(Array.from({ length: LIMITS.units }, () => null)).length, LIMITS.validationErrors);
|
||||||
|
if (!HAS_SAFE_INPUT_OPEN) return;
|
||||||
|
|
||||||
|
const result = runCli(JSON.stringify(Array.from({ length: LIMITS.units }, () => null)));
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /output capped at 100/);
|
||||||
|
assert(output.length < 20000, `unexpected output length ${output.length}`);
|
||||||
|
assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-coverage-ledger/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects malformed top-level data and excessive unit counts", () => {
|
||||||
|
assert.deepEqual(errorsFor({ units: [] }), ["$: expected a top-level array"]);
|
||||||
|
const tooMany = Array.from({ length: 10001 }, () => null);
|
||||||
|
const errors = errorsFor(tooMany);
|
||||||
|
assert.deepEqual(errors, ["$: exceeds 10000 coverage units"]);
|
||||||
|
|
||||||
|
const oversizedCollection = unit({ extra: Array.from({ length: LIMITS.collectionItems + 1 }, () => null) });
|
||||||
|
assert(errorsFor([oversizedCollection]).some((error) => error.includes("exceeds 1000 entries")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a canonical ID derived from near-maximum multibyte references", () => {
|
||||||
|
const canonicalRefs = {
|
||||||
|
surface: "\u6f22".repeat(1024),
|
||||||
|
boundary: "\u00e9".repeat(1024),
|
||||||
|
subsystem: "packages/api",
|
||||||
|
attack_class: "\u6f22".repeat(1023) + "\u00e9",
|
||||||
|
};
|
||||||
|
const value = unit({ canonical_refs: canonicalRefs });
|
||||||
|
assert(value.coverage_id.length > 16384, `coverage_id length ${value.coverage_id.length}`);
|
||||||
|
assert(value.coverage_id.length <= 65536, `coverage_id length ${value.coverage_id.length}`);
|
||||||
|
assert.deepEqual(errorsFor([value]), []);
|
||||||
|
|
||||||
|
if (HAS_SAFE_INPUT_OPEN) {
|
||||||
|
const result = runCli(JSON.stringify([value]));
|
||||||
|
assert.equal(result.status, 0, cliOutput(result));
|
||||||
|
}
|
||||||
|
});
|
||||||
773
.agents/skills/security-audit/validate-findings.cjs
Executable file
773
.agents/skills/security-audit/validate-findings.cjs
Executable file
@@ -0,0 +1,773 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates findings.json against report-schema.json.
|
||||||
|
* Usage: node validate-findings.cjs <path-to-findings.json>
|
||||||
|
*
|
||||||
|
* This is a dependency-free interpreter for the JSON Schema keywords used by
|
||||||
|
* report-schema.json, plus finding-specific checks that are clearer in code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { TextDecoder } = require("util");
|
||||||
|
|
||||||
|
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||||
|
const SUPPORTED_TYPES = new Set(["object", "array", "string", "integer", "number", "boolean", "null"]);
|
||||||
|
const SUPPORTED_KEYWORDS = new Set([
|
||||||
|
"$comment",
|
||||||
|
"additionalProperties",
|
||||||
|
"const",
|
||||||
|
"description",
|
||||||
|
"enum",
|
||||||
|
"items",
|
||||||
|
"minimum",
|
||||||
|
"minItems",
|
||||||
|
"minLength",
|
||||||
|
"oneOf",
|
||||||
|
"pattern",
|
||||||
|
"properties",
|
||||||
|
"required",
|
||||||
|
"type",
|
||||||
|
"uniqueItems",
|
||||||
|
"visibleContent",
|
||||||
|
]);
|
||||||
|
const SEVERITY_RANK = new Map([
|
||||||
|
["informational", 0],
|
||||||
|
["low", 1],
|
||||||
|
["medium", 2],
|
||||||
|
["high", 3],
|
||||||
|
["critical", 4],
|
||||||
|
]);
|
||||||
|
const LIMITS = Object.freeze({
|
||||||
|
inputBytes: 5 * 1024 * 1024,
|
||||||
|
nestingDepth: 64,
|
||||||
|
arrayItems: 1000,
|
||||||
|
canonicalKeyBytes: 1024 * 1024,
|
||||||
|
uniqueSetBytes: 5 * 1024 * 1024,
|
||||||
|
validationErrors: 100,
|
||||||
|
});
|
||||||
|
const VISIBLE_CONTENT = /[^\p{White_Space}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
|
||||||
|
const PATH_FORBIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
|
||||||
|
const WINDOWS_RESERVED_COMPONENT = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\.|$)/iu;
|
||||||
|
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||||
|
const UNSAFE_DIAGNOSTIC_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
|
||||||
|
const MAX_DIAGNOSTIC_STRING_LENGTH = 256;
|
||||||
|
|
||||||
|
class JsonStructureError extends Error {}
|
||||||
|
class SafeInputError extends Error {}
|
||||||
|
|
||||||
|
function escapeUnsafeDiagnosticCharacters(value) {
|
||||||
|
return String(value).replace(UNSAFE_DIAGNOSTIC_CHARACTER, (character) => {
|
||||||
|
const codePoint = character.codePointAt(0);
|
||||||
|
return codePoint <= 0xffff
|
||||||
|
? `\\u${codePoint.toString(16).padStart(4, "0")}`
|
||||||
|
: `\\u{${codePoint.toString(16)}}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeQuote(value) {
|
||||||
|
let serialized;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const clipped = value.length > MAX_DIAGNOSTIC_STRING_LENGTH
|
||||||
|
? `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...`
|
||||||
|
: value;
|
||||||
|
serialized = JSON.stringify(clipped);
|
||||||
|
} else if (value === null || typeof value === "boolean") {
|
||||||
|
serialized = String(value);
|
||||||
|
} else if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
serialized = String(value);
|
||||||
|
} else {
|
||||||
|
serialized = `"<${Array.isArray(value) ? "array" : typeof value}>"`;
|
||||||
|
}
|
||||||
|
return escapeUnsafeDiagnosticCharacters(serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyPath(base, key) {
|
||||||
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
|
||||||
|
? `${base}.${key}`
|
||||||
|
: `${base}[${safeQuote(key)}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createErrorList() {
|
||||||
|
const errors = [];
|
||||||
|
Object.defineProperty(errors, "push", {
|
||||||
|
value(...messages) {
|
||||||
|
const remaining = LIMITS.validationErrors - this.length;
|
||||||
|
if (remaining > 0) {
|
||||||
|
Array.prototype.push.apply(this, messages.slice(0, remaining).map(escapeUnsafeDiagnosticCharacters));
|
||||||
|
}
|
||||||
|
return this.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeOf(value) {
|
||||||
|
if (Array.isArray(value)) return "array";
|
||||||
|
if (value === null) return "null";
|
||||||
|
return typeof value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepEqual(left, right) {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (typeOf(left) !== typeOf(right)) return false;
|
||||||
|
if (Array.isArray(left)) {
|
||||||
|
return left.length === right.length && left.every((value, index) => deepEqual(value, right[index]));
|
||||||
|
}
|
||||||
|
if (left !== null && typeof left === "object") {
|
||||||
|
const leftKeys = Object.keys(left);
|
||||||
|
const rightKeys = Object.keys(right);
|
||||||
|
return leftKeys.length === rightKeys.length &&
|
||||||
|
leftKeys.every((key) => hasOwn(right, key) && deepEqual(left[key], right[key]));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codePointLength(value) {
|
||||||
|
let length = 0;
|
||||||
|
let index = 0;
|
||||||
|
while (index < value.length) {
|
||||||
|
const first = value.charCodeAt(index++);
|
||||||
|
if (first >= 0xd800 && first <= 0xdbff && index < value.length) {
|
||||||
|
const second = value.charCodeAt(index);
|
||||||
|
if (second >= 0xdc00 && second <= 0xdfff) index++;
|
||||||
|
}
|
||||||
|
length++;
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidUnicodeScalarValues(value) {
|
||||||
|
let index = 0;
|
||||||
|
while (index < value.length) {
|
||||||
|
const first = value.charCodeAt(index++);
|
||||||
|
if (first >= 0xd800 && first <= 0xdbff) {
|
||||||
|
if (index >= value.length) return false;
|
||||||
|
const second = value.charCodeAt(index++);
|
||||||
|
if (second < 0xdc00 || second > 0xdfff) return false;
|
||||||
|
} else if (first >= 0xdc00 && first <= 0xdfff) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasVisibleProse(value) {
|
||||||
|
return hasValidUnicodeScalarValues(value) && VISIBLE_CONTENT.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalKey(value) {
|
||||||
|
const chunks = [];
|
||||||
|
let bytes = 0;
|
||||||
|
|
||||||
|
function append(chunk) {
|
||||||
|
bytes += Buffer.byteLength(chunk);
|
||||||
|
if (bytes > LIMITS.canonicalKeyBytes) {
|
||||||
|
throw new Error(`canonical key exceeds ${LIMITS.canonicalKeyBytes} byte limit`);
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encode(item) {
|
||||||
|
const type = typeOf(item);
|
||||||
|
if (type === "null") {
|
||||||
|
append("null");
|
||||||
|
} else if (type === "string") {
|
||||||
|
append(`string:${JSON.stringify(item)}`);
|
||||||
|
} else if (type === "number") {
|
||||||
|
append(`number:${Object.is(item, -0) ? "0" : String(item)}`);
|
||||||
|
} else if (type === "boolean") {
|
||||||
|
append(`boolean:${item ? "true" : "false"}`);
|
||||||
|
} else if (type === "array") {
|
||||||
|
append("array:[");
|
||||||
|
item.forEach((entry, index) => {
|
||||||
|
if (index > 0) append(",");
|
||||||
|
encode(entry);
|
||||||
|
});
|
||||||
|
append("]");
|
||||||
|
} else if (type === "object") {
|
||||||
|
append("object:{");
|
||||||
|
Object.keys(item).sort().forEach((key, index) => {
|
||||||
|
if (index > 0) append(",");
|
||||||
|
append(JSON.stringify(key));
|
||||||
|
append(":");
|
||||||
|
encode(item[key]);
|
||||||
|
});
|
||||||
|
append("}");
|
||||||
|
} else {
|
||||||
|
append(`${type}:${String(item)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encode(value);
|
||||||
|
return { key: chunks.join(""), bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDataLimitErrors(value, location = "$data") {
|
||||||
|
const stack = [{ value, location, depth: value !== null && typeof value === "object" ? 1 : 0 }];
|
||||||
|
const seen = new WeakSet();
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (current.value === null || typeof current.value !== "object") continue;
|
||||||
|
if (current.depth > LIMITS.nestingDepth) {
|
||||||
|
return [escapeUnsafeDiagnosticCharacters(`${current.location}: exceeds ${LIMITS.nestingDepth} level nesting depth limit`)];
|
||||||
|
}
|
||||||
|
if (seen.has(current.value)) {
|
||||||
|
return [escapeUnsafeDiagnosticCharacters(`${current.location}: input must not contain repeated or cyclic object references`)];
|
||||||
|
}
|
||||||
|
seen.add(current.value);
|
||||||
|
|
||||||
|
if (Array.isArray(current.value)) {
|
||||||
|
if (current.value.length > LIMITS.arrayItems) {
|
||||||
|
return [escapeUnsafeDiagnosticCharacters(`${current.location}: exceeds ${LIMITS.arrayItems} item array limit`)];
|
||||||
|
}
|
||||||
|
for (let index = current.value.length - 1; index >= 0; index--) {
|
||||||
|
const child = current.value[index];
|
||||||
|
if (child !== null && typeof child === "object") {
|
||||||
|
stack.push({ value: child, location: `${current.location}[${index}]`, depth: current.depth + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const keys = Object.keys(current.value);
|
||||||
|
for (let index = keys.length - 1; index >= 0; index--) {
|
||||||
|
const key = keys[index];
|
||||||
|
const child = current.value[key];
|
||||||
|
if (child !== null && typeof child === "object") {
|
||||||
|
stack.push({ value: child, location: propertyPath(current.location, key), depth: current.depth + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSchemaErrors(schema, location = "schema") {
|
||||||
|
const errors = createErrorList();
|
||||||
|
|
||||||
|
function check(node, p) {
|
||||||
|
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
||||||
|
errors.push(`${p}: schema must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(node)) {
|
||||||
|
if (!SUPPORTED_KEYWORDS.has(key)) errors.push(`${p}: unsupported schema keyword ${safeQuote(key)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwn(node, "$comment") && typeof node.$comment !== "string") {
|
||||||
|
errors.push(`${p}.$comment: expected string`);
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "description") && typeof node.description !== "string") {
|
||||||
|
errors.push(`${p}.description: expected string`);
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "type") && (!SUPPORTED_TYPES.has(node.type))) {
|
||||||
|
errors.push(`${p}.type: unsupported type ${safeQuote(node.type)}`);
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "properties")) {
|
||||||
|
if (node.properties === null || typeof node.properties !== "object" || Array.isArray(node.properties)) {
|
||||||
|
errors.push(`${p}.properties: expected object`);
|
||||||
|
} else {
|
||||||
|
for (const key of Object.keys(node.properties)) check(node.properties[key], propertyPath(`${p}.properties`, key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "required")) {
|
||||||
|
if (!Array.isArray(node.required) || node.required.some((key) => typeof key !== "string")) {
|
||||||
|
errors.push(`${p}.required: expected an array of strings`);
|
||||||
|
} else if (new Set(node.required).size !== node.required.length) {
|
||||||
|
errors.push(`${p}.required: entries must be unique`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "additionalProperties") && typeof node.additionalProperties !== "boolean") {
|
||||||
|
errors.push(`${p}.additionalProperties: only boolean values are supported`);
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "enum")) {
|
||||||
|
if (!Array.isArray(node.enum) || node.enum.length === 0) {
|
||||||
|
errors.push(`${p}.enum: expected a non-empty array`);
|
||||||
|
} else {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const value of node.enum) {
|
||||||
|
let key;
|
||||||
|
try {
|
||||||
|
key = canonicalKey(value).key;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${p}.enum: ${error.message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (seen.has(key)) {
|
||||||
|
errors.push(`${p}.enum: entries must be unique`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "items")) check(node.items, `${p}.items`);
|
||||||
|
for (const keyword of ["minItems", "minLength"]) {
|
||||||
|
if (hasOwn(node, keyword) && (!Number.isInteger(node[keyword]) || node[keyword] < 0)) {
|
||||||
|
errors.push(`${p}.${keyword}: expected a non-negative integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "minimum") && (typeof node.minimum !== "number" || !Number.isFinite(node.minimum))) {
|
||||||
|
errors.push(`${p}.minimum: expected a finite number`);
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "pattern")) {
|
||||||
|
if (typeof node.pattern !== "string") {
|
||||||
|
errors.push(`${p}.pattern: expected string`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
new RegExp(node.pattern);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${p}.pattern: invalid regular expression`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "uniqueItems") && typeof node.uniqueItems !== "boolean") {
|
||||||
|
errors.push(`${p}.uniqueItems: expected boolean`);
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "visibleContent")) {
|
||||||
|
if (typeof node.visibleContent !== "boolean") {
|
||||||
|
errors.push(`${p}.visibleContent: expected boolean`);
|
||||||
|
} else if (node.visibleContent === true && node.type !== "string") {
|
||||||
|
errors.push(`${p}.visibleContent: requires type "string"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(node, "oneOf")) {
|
||||||
|
if (!Array.isArray(node.oneOf) || node.oneOf.length === 0) {
|
||||||
|
errors.push(`${p}.oneOf: expected a non-empty array`);
|
||||||
|
} else {
|
||||||
|
node.oneOf.forEach((branch, index) => check(branch, `${p}.oneOf[${index}]`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check(schema, location);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDiscriminator(schema) {
|
||||||
|
if (!hasOwn(schema, "properties") || typeof schema.properties !== "object") return null;
|
||||||
|
for (const key of Object.keys(schema.properties)) {
|
||||||
|
const subSchema = schema.properties[key];
|
||||||
|
if (subSchema && typeof subSchema === "object" && hasOwn(subSchema, "const")) {
|
||||||
|
return { key, value: subSchema.const };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(value, schema, p, errors) {
|
||||||
|
if (errors.length >= LIMITS.validationErrors) return;
|
||||||
|
if (hasOwn(schema, "oneOf")) {
|
||||||
|
const results = schema.oneOf.map((branch) => collectUnchecked(value, branch, p));
|
||||||
|
const passingIndexes = results
|
||||||
|
.map((branchErrors, index) => branchErrors.length === 0 ? index : -1)
|
||||||
|
.filter((index) => index !== -1);
|
||||||
|
|
||||||
|
if (passingIndexes.length !== 1) {
|
||||||
|
errors.push(`${p}: must match exactly one schema in oneOf; matched ${passingIndexes.length}`);
|
||||||
|
if (passingIndexes.length === 0 && value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const matchingDiscriminators = schema.oneOf
|
||||||
|
.map((branch, index) => ({ discriminator: findDiscriminator(branch), index }))
|
||||||
|
.filter(({ discriminator }) => discriminator && hasOwn(value, discriminator.key) && deepEqual(value[discriminator.key], discriminator.value));
|
||||||
|
if (matchingDiscriminators.length === 1) {
|
||||||
|
errors.push(...results[matchingDiscriminators[0].index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwn(schema, "const") && !deepEqual(value, schema.const)) {
|
||||||
|
errors.push(`${p}: must equal ${safeQuote(schema.const)}, got ${safeQuote(value)}`);
|
||||||
|
}
|
||||||
|
if (hasOwn(schema, "enum") && !schema.enum.some((allowed) => deepEqual(value, allowed))) {
|
||||||
|
const allowed = schema.enum.map(safeQuote).join(", ");
|
||||||
|
errors.push(`${p}: invalid value ${safeQuote(value)} (expected one of ${allowed})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwn(schema, "type") && typeOf(value) !== schema.type && !(schema.type === "integer" && typeOf(value) === "number" && Number.isInteger(value))) {
|
||||||
|
errors.push(`${p}: expected ${schema.type}, got ${typeOf(value)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeOf(value) === "object") {
|
||||||
|
for (const req of hasOwn(schema, "required") ? schema.required : []) {
|
||||||
|
if (!hasOwn(value, req)) errors.push(`${p}: missing required field ${safeQuote(req)}`);
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (hasOwn(schema, "properties") && hasOwn(schema.properties, key)) {
|
||||||
|
validate(value[key], schema.properties[key], propertyPath(p, key), errors);
|
||||||
|
} else if (hasOwn(schema, "additionalProperties") && schema.additionalProperties === false) {
|
||||||
|
errors.push(`${p}: unexpected field ${safeQuote(key)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (hasOwn(schema, "minItems") && value.length < schema.minItems) {
|
||||||
|
errors.push(`${p}: must have at least ${schema.minItems} item(s), got ${value.length}`);
|
||||||
|
}
|
||||||
|
if (hasOwn(schema, "uniqueItems") && schema.uniqueItems === true) {
|
||||||
|
const seen = new Set();
|
||||||
|
let setBytes = 0;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
let canonical;
|
||||||
|
try {
|
||||||
|
canonical = canonicalKey(value[i]);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${p}[${i}]: ${error.message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (seen.has(canonical.key)) {
|
||||||
|
errors.push(`${p}: items must be unique; duplicate at index ${i}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
setBytes += canonical.bytes;
|
||||||
|
if (setBytes > LIMITS.uniqueSetBytes) {
|
||||||
|
errors.push(`${p}: canonical uniqueness set exceeds ${LIMITS.uniqueSetBytes} byte limit`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen.add(canonical.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(schema, "items")) {
|
||||||
|
value.forEach((item, index) => validate(item, schema.items, `${p}[${index}]`, errors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (hasOwn(schema, "minLength") && codePointLength(value) < schema.minLength) {
|
||||||
|
errors.push(`${p}: must have at least ${schema.minLength} character(s)`);
|
||||||
|
}
|
||||||
|
if (schema.visibleContent === true) {
|
||||||
|
if (!hasValidUnicodeScalarValues(value)) {
|
||||||
|
errors.push(`${p}: must contain only valid Unicode scalar values`);
|
||||||
|
} else if (!VISIBLE_CONTENT.test(value)) {
|
||||||
|
errors.push(`${p}: must contain a visible character`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(schema, "pattern") && !(new RegExp(schema.pattern).test(value))) {
|
||||||
|
errors.push(`${p}: must match pattern ${JSON.stringify(schema.pattern)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number" && hasOwn(schema, "minimum") && value < schema.minimum) {
|
||||||
|
errors.push(`${p}: must be at least ${schema.minimum}, got ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUnchecked(value, schema, p) {
|
||||||
|
const errors = createErrorList();
|
||||||
|
validate(value, schema, p, errors);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(value, schema, p = "$data") {
|
||||||
|
const limitErrors = collectDataLimitErrors(value, p);
|
||||||
|
if (limitErrors.length > 0) return limitErrors;
|
||||||
|
return collectUnchecked(value, schema, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeRelativeSourcePath(value) {
|
||||||
|
if (typeof value !== "string" || value.length === 0 || !hasValidUnicodeScalarValues(value) || value.trim() !== value || PATH_FORBIDDEN_CHARACTER.test(value) || value.includes("\\") || value.includes(":")) return false;
|
||||||
|
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("~")) return false;
|
||||||
|
const segments = value.split("/");
|
||||||
|
return segments.every((segment) =>
|
||||||
|
segment !== "" &&
|
||||||
|
segment !== "." &&
|
||||||
|
segment !== ".." &&
|
||||||
|
!/[ .]$/u.test(segment) &&
|
||||||
|
!WINDOWS_RESERVED_COMPONENT.test(segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFindingSemanticErrors(findings) {
|
||||||
|
const errors = createErrorList();
|
||||||
|
if (!Array.isArray(findings)) return errors;
|
||||||
|
|
||||||
|
const fingerprints = new Map();
|
||||||
|
let previousFingerprint = null;
|
||||||
|
findings.forEach((finding, index) => {
|
||||||
|
if (errors.length >= LIMITS.validationErrors) return;
|
||||||
|
if (!finding || typeof finding !== "object" || Array.isArray(finding)) return;
|
||||||
|
const base = `$[${index}]`;
|
||||||
|
|
||||||
|
if (hasOwn(finding, "fingerprint") && typeof finding.fingerprint === "string") {
|
||||||
|
if (fingerprints.has(finding.fingerprint)) {
|
||||||
|
errors.push(`${base}.fingerprint: duplicate of $[${fingerprints.get(finding.fingerprint)}].fingerprint`);
|
||||||
|
} else {
|
||||||
|
fingerprints.set(finding.fingerprint, index);
|
||||||
|
}
|
||||||
|
if (previousFingerprint !== null && previousFingerprint > finding.fingerprint) {
|
||||||
|
errors.push(`${base}.fingerprint: findings must be sorted lexicographically`);
|
||||||
|
}
|
||||||
|
previousFingerprint = finding.fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ["trace", "evidence"]) {
|
||||||
|
if (!hasOwn(finding, field) || !Array.isArray(finding[field])) continue;
|
||||||
|
finding[field].forEach((entry, entryIndex) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
|
||||||
|
if (hasOwn(entry, "line") && (!Number.isInteger(entry.line) || entry.line < 1)) {
|
||||||
|
errors.push(`${base}.${field}[${entryIndex}].line: must be a positive integer`);
|
||||||
|
}
|
||||||
|
if (hasOwn(entry, "file") && !isSafeRelativeSourcePath(entry.file)) {
|
||||||
|
errors.push(`${base}.${field}[${entryIndex}].file: must be a safe repository-relative source path`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (finding.remediation && Array.isArray(finding.remediation.code_changes)) {
|
||||||
|
finding.remediation.code_changes.forEach((change, changeIndex) => {
|
||||||
|
if (change && hasOwn(change, "file_name") && !isSafeRelativeSourcePath(change.file_name)) {
|
||||||
|
errors.push(`${base}.remediation.code_changes[${changeIndex}].file_name: must be a safe repository-relative source path`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(finding.trace) && finding.trace.length === 1) {
|
||||||
|
const kind = finding.trace[0] && finding.trace[0].kind;
|
||||||
|
if (kind !== "entrypoint" && kind !== "sink") {
|
||||||
|
errors.push(`${base}.trace[0].kind: a one-line trace must be "entrypoint" or "sink"`);
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(finding.trace) && finding.trace.length > 1) {
|
||||||
|
const last = finding.trace.length - 1;
|
||||||
|
if (finding.trace[0] && finding.trace[0].kind !== "entrypoint") {
|
||||||
|
errors.push(`${base}.trace[0].kind: must be "entrypoint", got ${safeQuote(finding.trace[0].kind)}`);
|
||||||
|
}
|
||||||
|
if (finding.trace[last] && finding.trace[last].kind !== "sink") {
|
||||||
|
errors.push(`${base}.trace[${last}].kind: must be "sink", got ${safeQuote(finding.trace[last].kind)}`);
|
||||||
|
}
|
||||||
|
for (let traceIndex = 1; traceIndex < last; traceIndex++) {
|
||||||
|
if (finding.trace[traceIndex] && finding.trace[traceIndex].kind !== "propagation") {
|
||||||
|
errors.push(`${base}.trace[${traceIndex}].kind: must be "propagation", got ${safeQuote(finding.trace[traceIndex].kind)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = finding.verdict;
|
||||||
|
if (verdict === "confirmed") {
|
||||||
|
for (const forbidden of ["claimed_root_cause", "blockers", "validation_plan", "reason"]) {
|
||||||
|
if (hasOwn(finding, forbidden)) errors.push(`${base}: confirmed finding must not contain ${safeQuote(forbidden)}`);
|
||||||
|
}
|
||||||
|
if (!finding.execution || typeof finding.execution !== "object" || typeof finding.execution.observed_result !== "string" || !hasVisibleProse(finding.execution.observed_result)) {
|
||||||
|
errors.push(`${base}: confirmed finding requires a visible execution observed_result`);
|
||||||
|
}
|
||||||
|
if (!finding.remediation || typeof finding.remediation !== "object" || typeof finding.remediation.strategy !== "string" || !hasVisibleProse(finding.remediation.strategy)) {
|
||||||
|
errors.push(`${base}: confirmed finding requires visible remediation`);
|
||||||
|
}
|
||||||
|
const overall = finding.severity && finding.severity.overall_severity;
|
||||||
|
const impact = finding.severity && finding.severity.impact && finding.severity.impact.score;
|
||||||
|
if (SEVERITY_RANK.has(overall) && SEVERITY_RANK.has(impact) && SEVERITY_RANK.get(overall) > SEVERITY_RANK.get(impact)) {
|
||||||
|
errors.push(`${base}.severity.overall_severity: cannot exceed demonstrated impact ${safeQuote(impact)}`);
|
||||||
|
}
|
||||||
|
} else if (verdict === "needs_validation") {
|
||||||
|
if (hasOwn(finding, "severity")) errors.push(`${base}: needs_validation finding must not contain "severity"`);
|
||||||
|
for (const forbidden of ["execution", "remediation", "reason", "root_cause"]) {
|
||||||
|
if (hasOwn(finding, forbidden)) errors.push(`${base}: needs_validation finding must not contain ${safeQuote(forbidden)}`);
|
||||||
|
}
|
||||||
|
const plan = finding.validation_plan;
|
||||||
|
const hasLocalPlan = plan && typeof plan.local === "string" && hasVisibleProse(plan.local);
|
||||||
|
const hasDeploymentPlan = plan && typeof plan.deployment === "string" && hasVisibleProse(plan.deployment);
|
||||||
|
if (!hasLocalPlan && !hasDeploymentPlan) {
|
||||||
|
errors.push(`${base}.validation_plan: requires at least one visible local or deployment plan`);
|
||||||
|
}
|
||||||
|
} else if (verdict === "rejected") {
|
||||||
|
for (const forbidden of ["severity", "execution", "remediation", "blockers", "validation_plan", "root_cause"]) {
|
||||||
|
if (hasOwn(finding, forbidden)) errors.push(`${base}: rejected finding must not contain ${safeQuote(forbidden)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDocument(findings, schema) {
|
||||||
|
const schemaErrors = collectSchemaErrors(schema);
|
||||||
|
if (schemaErrors.length > 0) return schemaErrors;
|
||||||
|
const limitErrors = collectDataLimitErrors(findings, "$");
|
||||||
|
if (limitErrors.length > 0) return limitErrors;
|
||||||
|
const errors = collectUnchecked(findings, schema, "$");
|
||||||
|
if (errors.length < LIMITS.validationErrors) {
|
||||||
|
errors.push(...collectFindingSemanticErrors(findings));
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSchema(schemaPath) {
|
||||||
|
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
||||||
|
const errors = collectSchemaErrors(schema);
|
||||||
|
if (errors.length > 0) throw new Error(`unsupported or invalid report schema:\n${errors.join("\n")}`);
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileWithinLimit(file) {
|
||||||
|
const noFollow = fs.constants.O_NOFOLLOW;
|
||||||
|
const nonBlock = fs.constants.O_NONBLOCK;
|
||||||
|
if (!Number.isInteger(noFollow) || noFollow === 0 || !Number.isInteger(nonBlock) || nonBlock === 0) {
|
||||||
|
throw new SafeInputError("OS no-follow and nonblocking input protection is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
let descriptor;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && (error.code === "ELOOP" || error.code === "EMLINK")) {
|
||||||
|
throw new SafeInputError("input must not be a symlink");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = fs.fstatSync(descriptor);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throw new SafeInputError("input must be a regular file");
|
||||||
|
}
|
||||||
|
if (stat.size > LIMITS.inputBytes) {
|
||||||
|
throw new SafeInputError(`input exceeds ${LIMITS.inputBytes} byte limit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||||
|
let bytesRead = 0;
|
||||||
|
while (true) {
|
||||||
|
const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
||||||
|
if (count === 0) break;
|
||||||
|
bytesRead += count;
|
||||||
|
if (bytesRead > LIMITS.inputBytes) {
|
||||||
|
throw new SafeInputError(`input exceeds ${LIMITS.inputBytes} byte limit`);
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(buffer.subarray(0, count)));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return UTF8_DECODER.decode(Buffer.concat(chunks, bytesRead));
|
||||||
|
} catch {
|
||||||
|
throw new SafeInputError("input is not valid UTF-8");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceJsonTextLimits(contents) {
|
||||||
|
const containers = [];
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
function markArrayItem() {
|
||||||
|
const container = containers[containers.length - 1];
|
||||||
|
if (!container || container.type !== "array" || !container.expectsItem) return;
|
||||||
|
container.expectsItem = false;
|
||||||
|
container.items++;
|
||||||
|
if (container.items > LIMITS.arrayItems) {
|
||||||
|
throw new JsonStructureError(`input exceeds ${LIMITS.arrayItems} item array limit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < contents.length; index++) {
|
||||||
|
const character = contents[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (character === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
} else if (character === "\"") {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === "\"") {
|
||||||
|
markArrayItem();
|
||||||
|
inString = true;
|
||||||
|
} else if (character === "[" || character === "{") {
|
||||||
|
markArrayItem();
|
||||||
|
if (containers.length >= LIMITS.nestingDepth) {
|
||||||
|
throw new JsonStructureError(`input exceeds ${LIMITS.nestingDepth} level nesting depth limit`);
|
||||||
|
}
|
||||||
|
containers.push({
|
||||||
|
type: character === "[" ? "array" : "object",
|
||||||
|
expectsItem: character === "[",
|
||||||
|
items: 0,
|
||||||
|
});
|
||||||
|
} else if (character === "]" || character === "}") {
|
||||||
|
containers.pop();
|
||||||
|
} else if (character === ",") {
|
||||||
|
const container = containers[containers.length - 1];
|
||||||
|
if (container && container.type === "array") container.expectsItem = true;
|
||||||
|
} else if (!/\s/.test(character)) {
|
||||||
|
markArrayItem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(file) {
|
||||||
|
if (!file) {
|
||||||
|
console.error("Usage: node validate-findings.cjs <path-to-findings.json>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let schema;
|
||||||
|
try {
|
||||||
|
schema = loadSchema(path.join(__dirname, "report-schema.json"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load report-schema.json:", error.message);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents;
|
||||||
|
try {
|
||||||
|
contents = readFileWithinLimit(file);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof SafeInputError ? error.message : "input could not be opened or read safely";
|
||||||
|
console.error(`Failed to read findings JSON: ${reason}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
enforceJsonTextLimits(contents);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof JsonStructureError ? error.message : "invalid JSON structure";
|
||||||
|
console.error(`Failed to parse findings JSON: ${reason}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let findings;
|
||||||
|
try {
|
||||||
|
findings = JSON.parse(contents);
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to parse findings JSON: invalid JSON syntax");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors;
|
||||||
|
try {
|
||||||
|
errors = validateDocument(findings, schema);
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to validate findings JSON: unexpected validation error");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const message of errors) console.error("ERROR:", escapeUnsafeDiagnosticCharacters(message));
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const cap = errors.length === LIMITS.validationErrors ? `; output capped at ${LIMITS.validationErrors}` : "";
|
||||||
|
console.error(`FAIL: ${errors.length} validation error(s)${cap}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
console.log(`PASS: ${findings.length} findings valid`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
LIMITS,
|
||||||
|
PATH_FORBIDDEN_CHARACTER,
|
||||||
|
UNSAFE_DIAGNOSTIC_CHARACTER,
|
||||||
|
VISIBLE_CONTENT,
|
||||||
|
WINDOWS_RESERVED_COMPONENT,
|
||||||
|
collect,
|
||||||
|
collectFindingSemanticErrors,
|
||||||
|
collectSchemaErrors,
|
||||||
|
hasVisibleProse,
|
||||||
|
isSafeRelativeSourcePath,
|
||||||
|
validateDocument,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) process.exit(run(process.argv[2]));
|
||||||
652
.agents/skills/security-audit/validate-findings.test.cjs
Normal file
652
.agents/skills/security-audit/validate-findings.test.cjs
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
const test = require("node:test");
|
||||||
|
const schema = require("./report-schema.json");
|
||||||
|
const {
|
||||||
|
LIMITS,
|
||||||
|
collect,
|
||||||
|
collectSchemaErrors,
|
||||||
|
validateDocument,
|
||||||
|
} = require("./validate-findings.cjs");
|
||||||
|
|
||||||
|
const validatorPath = path.join(__dirname, "validate-findings.cjs");
|
||||||
|
const CLI_TIMEOUT_MS = 5000;
|
||||||
|
const HOSTILE_CLI_TIMEOUT_MS = 15000;
|
||||||
|
const HAS_SAFE_INPUT_OPEN = Number.isInteger(fs.constants.O_NOFOLLOW) &&
|
||||||
|
fs.constants.O_NOFOLLOW !== 0 &&
|
||||||
|
Number.isInteger(fs.constants.O_NONBLOCK) &&
|
||||||
|
fs.constants.O_NONBLOCK !== 0;
|
||||||
|
const TERMINAL_CONTROL_PAYLOAD = "\u001b\u0007\u0085\u202e\u034f\ufe0f";
|
||||||
|
const TERMINAL_CONTROL_BYTES = [
|
||||||
|
Buffer.from([0x1b]),
|
||||||
|
Buffer.from([0x07]),
|
||||||
|
Buffer.from("\u0085"),
|
||||||
|
Buffer.from("\u202e"),
|
||||||
|
Buffer.from("\u034f"),
|
||||||
|
Buffer.from("\ufe0f"),
|
||||||
|
];
|
||||||
|
|
||||||
|
function source(kind = "entrypoint", file = "src/handler.c", line = 10) {
|
||||||
|
return { kind, file, line, scope: "handle", description: "Attacker data reaches the operation." };
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidence(file = "src/handler.c", line = 10) {
|
||||||
|
return { file, line, description: "The source performs the operation without the required check." };
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmed() {
|
||||||
|
return {
|
||||||
|
verdict: "confirmed",
|
||||||
|
fingerprint: "src-handler-missing-check",
|
||||||
|
title: "Missing ownership check",
|
||||||
|
description: "An attacker can reach an operation without the intended ownership check.",
|
||||||
|
root_cause: "handle omits the ownership check before changing the object.",
|
||||||
|
intended_behavior: "Only the object's owner can change it.",
|
||||||
|
trace: [source("entrypoint"), source("propagation", "src/model.c", 20), source("sink", "src/store.c", 30)],
|
||||||
|
evidence: [evidence()],
|
||||||
|
conditions: [],
|
||||||
|
execution: {
|
||||||
|
attacker_perspective: "An unprivileged remote user with their own account.",
|
||||||
|
payloads: ["An object identifier owned by another user."],
|
||||||
|
instructions: ["Submit the identifier through the public operation."],
|
||||||
|
observed_result: "The other user's object changes.",
|
||||||
|
},
|
||||||
|
remediation: { strategy: "Check ownership before the state change." },
|
||||||
|
severity: {
|
||||||
|
likelihood: { score: "medium", reason: "The operation is directly reachable." },
|
||||||
|
impact: { score: "medium", reason: "The attacker changes one protected object." },
|
||||||
|
overall_severity: "medium",
|
||||||
|
},
|
||||||
|
confidence: { score: "high", reason: "The source path and result were reproduced." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsValidation() {
|
||||||
|
return {
|
||||||
|
verdict: "needs_validation",
|
||||||
|
fingerprint: "src-parser-size-hypothesis",
|
||||||
|
title: "Unchecked parsed size",
|
||||||
|
description: "A parsed size may reach an allocation without a limit.",
|
||||||
|
claimed_root_cause: "parse_size may pass an unbounded value to allocate.",
|
||||||
|
trace: [source()],
|
||||||
|
evidence: [evidence()],
|
||||||
|
blockers: ["The generated parser source is absent from this checkout."],
|
||||||
|
validation_plan: {
|
||||||
|
local: "Generate the parser and submit the smallest input that exceeds the documented limit.",
|
||||||
|
deployment: "In an approved test deployment, confirm the request reaches the generated parser and record the bounded observable result.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejected() {
|
||||||
|
return {
|
||||||
|
verdict: "rejected",
|
||||||
|
fingerprint: "src-router-auth-bypass",
|
||||||
|
title: "Authorization bypass in router",
|
||||||
|
description: "The candidate claimed a route bypassed authorization.",
|
||||||
|
claimed_root_cause: "dispatch was claimed to skip the authorization wrapper.",
|
||||||
|
trace: [source("sink")],
|
||||||
|
evidence: [evidence()],
|
||||||
|
reason: "All routes pass through the authorization wrapper before dispatch.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorsFor(value) {
|
||||||
|
return validateDocument(value, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(contents, options = {}) {
|
||||||
|
const { nodeArgs = [], timeout = CLI_TIMEOUT_MS } = options;
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-"));
|
||||||
|
const findingsPath = path.join(directory, "findings.json");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(findingsPath, contents);
|
||||||
|
return spawnSync(process.execPath, [...nodeArgs, validatorPath, findingsPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliOutput(result) {
|
||||||
|
return `${result.stdout}${result.stderr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoInjectedControlBytes(output) {
|
||||||
|
const bytes = Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8");
|
||||||
|
for (const marker of TERMINAL_CONTROL_BYTES) {
|
||||||
|
assert.equal(bytes.indexOf(marker), -1, `found raw control bytes ${marker.toString("hex")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function producerShapedFindings() {
|
||||||
|
const demonstrated = confirmed();
|
||||||
|
demonstrated.conditions = [{
|
||||||
|
kind: "authentication_level",
|
||||||
|
description: "The attacker needs a normal account.",
|
||||||
|
}];
|
||||||
|
demonstrated.execution.payloads = ["", " \t\r\n", "\u0000\u001f\u007f", "\u034f", "\ufe0f", "\ud800", "\udc00", "[{,}]\\\""];
|
||||||
|
demonstrated.remediation.code_changes = [{
|
||||||
|
file_name: "src/handler.c",
|
||||||
|
fixed_code: "",
|
||||||
|
}];
|
||||||
|
|
||||||
|
const blocked = needsValidation();
|
||||||
|
delete blocked.validation_plan.deployment;
|
||||||
|
|
||||||
|
return [demonstrated, blocked, rejected()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectMutation(factory, mutate) {
|
||||||
|
const value = factory();
|
||||||
|
mutate(value);
|
||||||
|
assert.notEqual(errorsFor([value]).length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("schema is an actual top-level array with exactly three branches", () => {
|
||||||
|
assert.equal(schema.type, "array");
|
||||||
|
assert.equal(schema.items.oneOf.length, 3);
|
||||||
|
assert.deepEqual(schema.items.oneOf.map((branch) => branch.properties.verdict.const), [
|
||||||
|
"confirmed", "needs_validation", "rejected",
|
||||||
|
]);
|
||||||
|
const confirmedSchema = schema.items.oneOf[0].properties;
|
||||||
|
assert.equal(confirmedSchema.title.visibleContent, true);
|
||||||
|
assert.equal(confirmedSchema.execution.properties.payloads.items.minLength, undefined);
|
||||||
|
assert.equal(confirmedSchema.execution.properties.payloads.items.visibleContent, undefined);
|
||||||
|
assert.equal(confirmedSchema.remediation.properties.code_changes.items.properties.fixed_code.minLength, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a producer-shaped findings document through the CLI", () => {
|
||||||
|
const result = runCli(JSON.stringify(producerShapedFindings()));
|
||||||
|
assert.equal(result.status, 0, cliOutput(result));
|
||||||
|
assert.match(result.stdout, /PASS: 3 findings valid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts empty output and each complete branch", () => {
|
||||||
|
assert.deepEqual(errorsFor([]), []);
|
||||||
|
assert.deepEqual(errorsFor([confirmed(), needsValidation(), rejected()]), []);
|
||||||
|
const localOnly = needsValidation();
|
||||||
|
delete localOnly.validation_plan.deployment;
|
||||||
|
assert.deepEqual(errorsFor([localOnly]), []);
|
||||||
|
const deploymentOnly = needsValidation();
|
||||||
|
delete deploymentOnly.validation_plan.local;
|
||||||
|
assert.deepEqual(errorsFor([deploymentOnly]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows a one-line finding trace", () => {
|
||||||
|
const finding = confirmed();
|
||||||
|
finding.trace = [source("entrypoint")];
|
||||||
|
assert.deepEqual(errorsFor([finding]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects empty required content", () => {
|
||||||
|
const cases = [
|
||||||
|
[confirmed, (finding) => { finding.title = ""; }],
|
||||||
|
[confirmed, (finding) => { finding.title = " "; }],
|
||||||
|
[confirmed, (finding) => { finding.evidence = []; }],
|
||||||
|
[confirmed, (finding) => { finding.execution.payloads = []; }],
|
||||||
|
[confirmed, (finding) => { finding.execution.instructions = []; }],
|
||||||
|
[confirmed, (finding) => { finding.execution.observed_result = ""; }],
|
||||||
|
[confirmed, (finding) => { finding.remediation.strategy = ""; }],
|
||||||
|
[needsValidation, (finding) => { finding.blockers = []; }],
|
||||||
|
[needsValidation, (finding) => { finding.validation_plan = {}; }],
|
||||||
|
[needsValidation, (finding) => { finding.validation_plan = { local: " " }; }],
|
||||||
|
[rejected, (finding) => { finding.claimed_root_cause = ""; }],
|
||||||
|
];
|
||||||
|
for (const [factory, mutate] of cases) rejectMutation(factory, mutate);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves exact payload and replacement-code strings", () => {
|
||||||
|
const finding = confirmed();
|
||||||
|
const payloads = ["", " \t\r\n", "\u0000\u001f\u007f", "\u034f", "\ufe0f", "\ud800", "\udc00"];
|
||||||
|
const fixedCode = "\u0000 \t\r\n\u001f\u007f\u034f\ufe0f\ud800x\udc00";
|
||||||
|
finding.execution.payloads = payloads.slice();
|
||||||
|
finding.remediation.code_changes = [{ file_name: "src/handler.c", fixed_code: fixedCode }];
|
||||||
|
|
||||||
|
assert.deepEqual(errorsFor([finding]), []);
|
||||||
|
assert.deepEqual(finding.execution.payloads, payloads);
|
||||||
|
assert.equal(finding.remediation.code_changes[0].fixed_code, fixedCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid scalars and whitespace, control, format, or default-ignorable prose", () => {
|
||||||
|
for (const invisible of ["\u0000\t\r\n\u001f\u007f\u200b", "\u034f", "\ufe0f", "\ud800", "\udc00", "visible\ud800"]) {
|
||||||
|
const cases = [
|
||||||
|
[confirmed, (finding) => { finding.title = invisible; }],
|
||||||
|
[confirmed, (finding) => { finding.trace[0].scope = invisible; }],
|
||||||
|
[confirmed, (finding) => { finding.evidence[0].description = invisible; }],
|
||||||
|
[confirmed, (finding) => { finding.execution.instructions = [invisible]; }],
|
||||||
|
[confirmed, (finding) => { finding.remediation.strategy = invisible; }],
|
||||||
|
[confirmed, (finding) => { finding.severity.impact.reason = invisible; }],
|
||||||
|
[confirmed, (finding) => { finding.confidence.reason = invisible; }],
|
||||||
|
[needsValidation, (finding) => { finding.blockers = [invisible]; }],
|
||||||
|
[needsValidation, (finding) => { finding.validation_plan = { local: invisible }; }],
|
||||||
|
[rejected, (finding) => { finding.reason = invisible; }],
|
||||||
|
];
|
||||||
|
for (const [factory, mutate] of cases) rejectMutation(factory, mutate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotes input-derived controls in direct validation values and paths", () => {
|
||||||
|
const finding = confirmed();
|
||||||
|
finding.trace[0].kind = `invalid-${TERMINAL_CONTROL_PAYLOAD}`;
|
||||||
|
finding.execution[`extra-${TERMINAL_CONTROL_PAYLOAD}`] = "value";
|
||||||
|
|
||||||
|
const cyclic = {};
|
||||||
|
cyclic[`path-${TERMINAL_CONTROL_PAYLOAD}`] = cyclic;
|
||||||
|
const output = [
|
||||||
|
...errorsFor([finding]),
|
||||||
|
...collect(cyclic, { type: "object" }, "$input"),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
for (const escaped of ["\\u001b", "\\u0007", "\\u0085", "\\u202e", "\\u034f", "\\ufe0f"]) {
|
||||||
|
assert(output.includes(escaped), `missing escaped diagnostic ${escaped}`);
|
||||||
|
}
|
||||||
|
assertNoInjectedControlBytes(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects line zero", () => {
|
||||||
|
rejectMutation(confirmed, (finding) => { finding.trace[0].line = 0; });
|
||||||
|
rejectMutation(rejected, (finding) => { finding.evidence[0].line = 0; });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not treat inherited or Object-prototype properties as schema properties", () => {
|
||||||
|
rejectMutation(confirmed, (finding) => { finding.constructor = "not allowed"; });
|
||||||
|
const inherited = Object.create({ verdict: "confirmed" });
|
||||||
|
assert(errorsFor([inherited]).some((error) => error.includes("exactly one")));
|
||||||
|
assert(collect(Object.create({ constructor: "inherited" }), {
|
||||||
|
type: "object",
|
||||||
|
properties: { constructor: { type: "string" } },
|
||||||
|
required: ["constructor"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}).some((error) => error.includes("missing required")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oneOf requires exactly one passing branch", () => {
|
||||||
|
assert(collect("value", { oneOf: [{ type: "string" }, { minLength: 1 }] }, "$test")
|
||||||
|
.some((error) => error.includes("matched 2")));
|
||||||
|
assert(collect(7, { oneOf: [{ type: "string" }, { minimum: 10 }] }, "$test")
|
||||||
|
.some((error) => error.includes("matched 0")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects duplicate fingerprints and unique array entries", () => {
|
||||||
|
const first = confirmed();
|
||||||
|
const second = rejected();
|
||||||
|
second.fingerprint = first.fingerprint;
|
||||||
|
assert(errorsFor([first, second]).some((error) => error.includes("duplicate of")));
|
||||||
|
rejectMutation(needsValidation, (finding) => { finding.blockers = [finding.blockers[0], finding.blockers[0]]; });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses canonical Set uniqueness for structured entries at the array limit", () => {
|
||||||
|
const entries = Array.from({ length: LIMITS.arrayItems }, (_, id) => ({ id, label: String(id) }));
|
||||||
|
assert.deepEqual(collect(entries, { type: "array", uniqueItems: true }), []);
|
||||||
|
|
||||||
|
const duplicate = entries.slice(0, -1);
|
||||||
|
duplicate.push({ label: "0", id: 0 });
|
||||||
|
assert(collect(duplicate, { type: "array", uniqueItems: true })
|
||||||
|
.some((error) => error.includes(`duplicate at index ${LIMITS.arrayItems - 1}`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bounds canonical uniqueness keys and Set storage", () => {
|
||||||
|
const oversizedKey = "x".repeat(LIMITS.canonicalKeyBytes + 1);
|
||||||
|
assert(collect([oversizedKey], { type: "array", uniqueItems: true })
|
||||||
|
.some((error) => error.includes("canonical key exceeds")));
|
||||||
|
|
||||||
|
const itemLength = Math.floor(LIMITS.uniqueSetBytes / 6);
|
||||||
|
const largeUniqueItems = Array.from({ length: 6 }, (_, index) => `${index}${"x".repeat(itemLength)}`);
|
||||||
|
assert(collect(largeUniqueItems, { type: "array", uniqueItems: true })
|
||||||
|
.some((error) => error.includes("canonical uniqueness set exceeds")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires findings to be sorted by fingerprint", () => {
|
||||||
|
const first = confirmed();
|
||||||
|
const second = rejected();
|
||||||
|
assert(errorsFor([second, first]).some((error) => error.includes("sorted lexicographically")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects severity above demonstrated impact", () => {
|
||||||
|
rejectMutation(confirmed, (finding) => {
|
||||||
|
finding.severity.overall_severity = "high";
|
||||||
|
finding.severity.impact.score = "medium";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unsafe source paths", () => {
|
||||||
|
const badPaths = [
|
||||||
|
"/etc/passwd",
|
||||||
|
"../src/file.c",
|
||||||
|
"src/../file.c",
|
||||||
|
"src//file.c",
|
||||||
|
"C:\\src\\file.c",
|
||||||
|
"src/file:name.c",
|
||||||
|
"src/file\nname.c",
|
||||||
|
"src/file\u0001name.c",
|
||||||
|
"src/file\u0085name.c",
|
||||||
|
"src/file\u2028name.c",
|
||||||
|
"src/file\u202ename.c",
|
||||||
|
"src/file\u2066name.c",
|
||||||
|
"src/file\u200dname.c",
|
||||||
|
"src/file\u034fname.c",
|
||||||
|
"src/file\ufe0fname.c",
|
||||||
|
"src/file\ud800name.c",
|
||||||
|
"src/file\udc00name.c",
|
||||||
|
"CON",
|
||||||
|
"src/con.txt",
|
||||||
|
"src/PRN",
|
||||||
|
"src/AUX.c",
|
||||||
|
"src/NUL",
|
||||||
|
"src/COM1.log",
|
||||||
|
"src/lpt9",
|
||||||
|
"src/CONIN$",
|
||||||
|
"src/CONOUT$.txt",
|
||||||
|
"src/CLOCK$.txt",
|
||||||
|
"src/COM\u00b9.log",
|
||||||
|
"src/LPT\u00b2.log",
|
||||||
|
"src /file.c",
|
||||||
|
"src./file.c",
|
||||||
|
"src/file.c ",
|
||||||
|
"src/file.c.",
|
||||||
|
];
|
||||||
|
for (const badPath of badPaths) {
|
||||||
|
rejectMutation(confirmed, (finding) => { finding.trace[0].file = badPath; });
|
||||||
|
}
|
||||||
|
rejectMutation(rejected, (finding) => { finding.evidence[0].file = "NUL.txt"; });
|
||||||
|
rejectMutation(confirmed, (finding) => {
|
||||||
|
finding.remediation.code_changes = [{ file_name: "src/file:name.c", fixed_code: "replacement" }];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts legitimate Unicode source paths and prose", () => {
|
||||||
|
const finding = confirmed();
|
||||||
|
finding.title = "Finding \ud83d\ude00 cafe\u0301";
|
||||||
|
finding.trace[0].file = "src/日本語/cafe\u0301-\ud83d\ude00.ts";
|
||||||
|
finding.evidence[0].file = "src/mañana/файл.ts";
|
||||||
|
finding.remediation.code_changes = [{
|
||||||
|
file_name: "src/修正/éxito.ts",
|
||||||
|
fixed_code: "replacement",
|
||||||
|
}];
|
||||||
|
assert.deepEqual(errorsFor([finding]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects input above the byte limit without an exception trace", () => {
|
||||||
|
const result = runCli(Buffer.alloc(LIMITS.inputBytes + 1, 0x20));
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, new RegExp(`input exceeds ${LIMITS.inputBytes} byte limit`));
|
||||||
|
assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects invalid UTF-8 without replacement or an exception trace", () => {
|
||||||
|
const findings = producerShapedFindings();
|
||||||
|
findings[0].execution.payloads = ["INVALID_UTF8"];
|
||||||
|
const encoded = Buffer.from(JSON.stringify(findings));
|
||||||
|
const marker = Buffer.from("INVALID_UTF8");
|
||||||
|
const markerOffset = encoded.indexOf(marker);
|
||||||
|
assert.notEqual(markerOffset, -1);
|
||||||
|
const malformed = Buffer.concat([
|
||||||
|
encoded.subarray(0, markerOffset),
|
||||||
|
Buffer.from([0x80]),
|
||||||
|
encoded.subarray(markerOffset + marker.length),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = runCli(malformed);
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /input is not valid UTF-8/);
|
||||||
|
assert.doesNotMatch(output, /TypeError|stack|at validate-findings/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotes input-derived controls in CLI validation errors", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const finding = confirmed();
|
||||||
|
finding.trace[0].kind = `invalid-${TERMINAL_CONTROL_PAYLOAD}`;
|
||||||
|
finding.execution[`extra-${TERMINAL_CONTROL_PAYLOAD}`] = "value";
|
||||||
|
const result = runCli(JSON.stringify([finding]));
|
||||||
|
|
||||||
|
assert.equal(result.status, 1, cliOutput(result));
|
||||||
|
assert.match(result.stderr, /\$\[0\]\.trace\[0\]\.kind/);
|
||||||
|
assert.match(result.stderr, /\\u001b/);
|
||||||
|
assert.match(result.stderr, /\\u202e/);
|
||||||
|
assertNoInjectedControlBytes(result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a generic syntax error without parser-supplied controls", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const malformed = Buffer.concat([
|
||||||
|
Buffer.from("["),
|
||||||
|
Buffer.from(TERMINAL_CONTROL_PAYLOAD),
|
||||||
|
Buffer.from("]"),
|
||||||
|
]);
|
||||||
|
const result = runCli(malformed);
|
||||||
|
|
||||||
|
assert.equal(result.status, 1, cliOutput(result));
|
||||||
|
assert.equal(result.stderr, "Failed to parse findings JSON: invalid JSON syntax\n");
|
||||||
|
assertNoInjectedControlBytes(result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not reflect controls from a failed CLI input path", () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-path-"));
|
||||||
|
const missingPath = path.join(directory, `missing-${TERMINAL_CONTROL_PAYLOAD}.json`);
|
||||||
|
try {
|
||||||
|
const result = spawnSync(process.execPath, [validatorPath, missingPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: CLI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1, cliOutput(result));
|
||||||
|
assert.match(result.stderr, /Failed to read findings JSON:/);
|
||||||
|
assertNoInjectedControlBytes(result.stderr);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects lone-surrogate prose without changing payload semantics", () => {
|
||||||
|
const findings = producerShapedFindings();
|
||||||
|
findings[0].title = "\ud800";
|
||||||
|
const result = runCli(JSON.stringify(findings));
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /must contain only valid Unicode scalar values/);
|
||||||
|
assert.doesNotMatch(output, /stack|at validate-findings/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects Unicode format controls in source paths", () => {
|
||||||
|
const findings = producerShapedFindings();
|
||||||
|
findings[0].trace[0].file = "src/file\u202ename.c";
|
||||||
|
const result = runCli(JSON.stringify(findings));
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /must be a safe repository-relative source path/);
|
||||||
|
assert.doesNotMatch(output, /stack|at validate-findings/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects a FIFO without blocking", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-fifo-"));
|
||||||
|
const fifoPath = path.join(directory, "findings.json");
|
||||||
|
try {
|
||||||
|
const created = spawnSync("mkfifo", [fifoPath], { encoding: "utf8", timeout: CLI_TIMEOUT_MS });
|
||||||
|
assert.equal(created.status, 0, cliOutput(created));
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, [validatorPath, fifoPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: CLI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /input must be a regular file/);
|
||||||
|
assert.doesNotMatch(output, /stack|at validate-findings/i);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects a symlink without following it", { skip: process.platform === "win32" || !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "validate-findings-symlink-"));
|
||||||
|
const targetPath = path.join(directory, "target.json");
|
||||||
|
const symlinkPath = path.join(directory, "findings.json");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(targetPath, JSON.stringify(producerShapedFindings()));
|
||||||
|
fs.symlinkSync(targetPath, symlinkPath);
|
||||||
|
const result = spawnSync(process.execPath, [validatorPath, symlinkPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: CLI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /input must not be a symlink/);
|
||||||
|
assert.doesNotMatch(output, /stack|at validate-findings/i);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects input above the nesting-depth limit without an exception trace", () => {
|
||||||
|
const levels = LIMITS.nestingDepth + 1;
|
||||||
|
const result = runCli(`${"[".repeat(levels)}0${"]".repeat(levels)}`);
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, new RegExp(`${LIMITS.nestingDepth} level nesting depth limit`));
|
||||||
|
assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CLI rejects an oversized array without an exception trace", () => {
|
||||||
|
const result = runCli(JSON.stringify(Array(LIMITS.arrayItems + 1).fill(null)));
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, new RegExp(`${LIMITS.arrayItems} item array limit`));
|
||||||
|
assert.doesNotMatch(output, /RangeError|Maximum call stack|heap out of memory/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checks pattern and branch invariants", () => {
|
||||||
|
rejectMutation(rejected, (finding) => { finding.fingerprint = "not stable"; });
|
||||||
|
rejectMutation(needsValidation, (finding) => {
|
||||||
|
finding.severity = { impact: { score: "low" }, overall_severity: "low" };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unsupported and malformed schema keywords", () => {
|
||||||
|
assert(collectSchemaErrors({ type: "string", format: "uuid" }).some((error) => error.includes("format")));
|
||||||
|
assert(collectSchemaErrors({ type: "string", pattern: "[" }).some((error) => error.includes("regular expression")));
|
||||||
|
assert(collectSchemaErrors({ type: "string", visibleContent: "yes" }).some((error) => error.includes("expected boolean")));
|
||||||
|
assert(collectSchemaErrors({ type: "array", visibleContent: true }).some((error) => error.includes("requires type")));
|
||||||
|
assert.notEqual(validateDocument([], { type: "array", maxItems: 1 }).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caps malformed 1000-finding validation output", () => {
|
||||||
|
assert.equal(errorsFor(Array.from({ length: LIMITS.arrayItems }, () => null)).length, LIMITS.validationErrors);
|
||||||
|
if (!HAS_SAFE_INPUT_OPEN) return;
|
||||||
|
|
||||||
|
const result = runCli(JSON.stringify(Array.from({ length: LIMITS.arrayItems }, () => null)));
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /output capped at 100/);
|
||||||
|
assert(output.length < 20000, `unexpected output length ${output.length}`);
|
||||||
|
assert.doesNotMatch(output, /RangeError|Maximum call stack|stack|at validate-findings/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caps amplified in-limit findings output under a constrained Node heap", { skip: !HAS_SAFE_INPUT_OPEN }, () => {
|
||||||
|
const findings = Array.from({ length: 750 }, () => ({
|
||||||
|
verdict: "confirmed",
|
||||||
|
trace: Array.from({ length: LIMITS.arrayItems }, () => 0),
|
||||||
|
evidence: Array.from({ length: LIMITS.arrayItems }, () => 0),
|
||||||
|
}));
|
||||||
|
const contents = JSON.stringify(findings);
|
||||||
|
assert(contents.length > 3 * 1000 * 1000, `hostile input too small: ${contents.length}`);
|
||||||
|
assert(contents.length <= LIMITS.inputBytes, `hostile input over limit: ${contents.length}`);
|
||||||
|
|
||||||
|
const result = runCli(contents, {
|
||||||
|
nodeArgs: ["--max-old-space-size=64"],
|
||||||
|
timeout: HOSTILE_CLI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const output = cliOutput(result);
|
||||||
|
assert.notEqual(result.error && result.error.code, "ETIMEDOUT", output);
|
||||||
|
assert.equal(result.status, 1, output);
|
||||||
|
assert.match(output, /output capped at 100/);
|
||||||
|
assert(output.length < 20000, `unexpected output length ${output.length}`);
|
||||||
|
assert.doesNotMatch(output, /heap out of memory|allocation failed|RangeError|Maximum call stack/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps shared helpers aligned with the coverage-ledger validator", () => {
|
||||||
|
const findingsModule = require("./validate-findings.cjs");
|
||||||
|
const ledgerModule = require("./validate-coverage-ledger.cjs");
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
"VISIBLE_CONTENT",
|
||||||
|
"PATH_FORBIDDEN_CHARACTER",
|
||||||
|
"WINDOWS_RESERVED_COMPONENT",
|
||||||
|
"UNSAFE_DIAGNOSTIC_CHARACTER",
|
||||||
|
]) {
|
||||||
|
assert.equal(findingsModule[name].source, ledgerModule[name].source, `${name} source`);
|
||||||
|
assert.equal(findingsModule[name].flags, ledgerModule[name].flags, `${name} flags`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedLimitKeys = Object.keys(findingsModule.LIMITS)
|
||||||
|
.filter((key) => Object.prototype.hasOwnProperty.call(ledgerModule.LIMITS, key))
|
||||||
|
.sort();
|
||||||
|
assert.deepEqual(sharedLimitKeys, ["inputBytes", "nestingDepth", "validationErrors"]);
|
||||||
|
for (const key of sharedLimitKeys) {
|
||||||
|
assert.equal(findingsModule.LIMITS[key], ledgerModule.LIMITS[key], `LIMITS.${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathCorpus = [
|
||||||
|
"src/handler.js",
|
||||||
|
"src/caf\u00e9/handler.js",
|
||||||
|
"src/\u65e5\u672c\u8a9e/\u0444\u0430\u0439\u043b.ts",
|
||||||
|
"src/cloc\u212a$.txt",
|
||||||
|
"src/CLOCK$.txt",
|
||||||
|
"src/con.txt",
|
||||||
|
"CON",
|
||||||
|
"src/COM\u00b9.log",
|
||||||
|
"src/lpt\u00b3",
|
||||||
|
"/etc/passwd",
|
||||||
|
"../src/file.c",
|
||||||
|
"src/../file.c",
|
||||||
|
"src//file.c",
|
||||||
|
"src\\file.c",
|
||||||
|
"src/file:name.c",
|
||||||
|
"~home/file.c",
|
||||||
|
"C:/file.c",
|
||||||
|
"src/file.c ",
|
||||||
|
"src/file.c.",
|
||||||
|
"src/file\u202ename.c",
|
||||||
|
"src/file\u200b.js",
|
||||||
|
"src/file\u034f.js",
|
||||||
|
"src/file\ufe0f.js",
|
||||||
|
"src/file\ud800name.c",
|
||||||
|
"src/file\udc00name.c",
|
||||||
|
];
|
||||||
|
for (const value of pathCorpus) {
|
||||||
|
assert.equal(
|
||||||
|
findingsModule.isSafeRelativeSourcePath(value),
|
||||||
|
ledgerModule.isSafeRelativePath(value),
|
||||||
|
`path verdict diverges for ${JSON.stringify(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(findingsModule.isSafeRelativeSourcePath("src/cloc\u212a$.txt"), false);
|
||||||
|
assert.equal(ledgerModule.isSafeRelativePath("src/cloc\u212a$.txt"), false);
|
||||||
|
|
||||||
|
const proseCorpus = [
|
||||||
|
"Valid prose.",
|
||||||
|
"caf\u00e9",
|
||||||
|
"",
|
||||||
|
" \t\r\n",
|
||||||
|
"\u200b",
|
||||||
|
"\u034f",
|
||||||
|
"\ufe0f",
|
||||||
|
"\ud800",
|
||||||
|
"\udc00",
|
||||||
|
"visible\ud800",
|
||||||
|
];
|
||||||
|
for (const value of proseCorpus) {
|
||||||
|
assert.equal(
|
||||||
|
findingsModule.hasVisibleProse(value),
|
||||||
|
ledgerModule.hasVisibleProse(value),
|
||||||
|
`prose verdict diverges for ${JSON.stringify(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
9
.claude/settings.json
Normal file
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ffprobe -v error *)",
|
||||||
|
"Bash(env)",
|
||||||
|
"mcp__outline__read_document"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(rtk grep *)",
|
||||||
|
"Bash(rtk read *)",
|
||||||
|
"Bash(rtk git *)"
|
||||||
|
],
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/config/.claude/skills/security-audit",
|
||||||
|
"/config/security-audit-skill",
|
||||||
|
"/config/.cargo/registry"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.claude/skills/security-audit
Symbolic link
1
.claude/skills/security-audit
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/security-audit
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/test-results
|
/test-results
|
||||||
/playwright-report
|
/playwright-report
|
||||||
|
/web/dist
|
||||||
|
|||||||
13
.rtk/filters.toml
Normal file
13
.rtk/filters.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Project-local RTK filters — commit this file with your repo.
|
||||||
|
# Filters here override user-global and built-in filters.
|
||||||
|
# Docs: https://github.com/rtk-ai/rtk#custom-filters
|
||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
# Example: suppress build noise from a custom tool
|
||||||
|
# [filters.my-tool]
|
||||||
|
# description = "Compact my-tool output"
|
||||||
|
# match_command = "^my-tool\\s+build"
|
||||||
|
# strip_ansi = true
|
||||||
|
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
|
||||||
|
# max_lines = 30
|
||||||
|
# on_empty = "my-tool: ok"
|
||||||
160
CHANGELOG.md
160
CHANGELOG.md
@@ -5,11 +5,158 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
The long form, with what was wrong before and how it was found, is in
|
|
||||||
[docs/history.md](docs/history.md).
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
|
||||||
|
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
|
||||||
|
across, everything in one go.
|
||||||
|
- The catalogue of feeds and the server settings the admin page edits are kept in the database
|
||||||
|
rather than config.toml, which keeps where things are, the torrent settings and who may sign
|
||||||
|
in. The first start takes them from config.toml and trims it, keeping the original as
|
||||||
|
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
|
||||||
|
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
|
||||||
|
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
|
||||||
|
be opened by a 0.7 release first, which brings its tables up to date.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
|
||||||
|
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
|
||||||
|
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
|
||||||
|
A theme chosen before this carries over.
|
||||||
|
- Your theme is kept on your account rather than in the browser, so it follows you to another
|
||||||
|
browser or computer, and the page arrives in it with no flash of the default. The theme a
|
||||||
|
browser already had is saved to your account the first time you load the page.
|
||||||
|
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
|
||||||
|
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
|
||||||
|
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
||||||
|
swipe the item you are reading left for the next one and right for the one before, or back
|
||||||
|
to the list from the first.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The server's settings, the accounts and the log are on their own admin page, /admin, reached by
|
||||||
|
the wrench in the header. Only an admin is sent the page, its script, or the link to it.
|
||||||
|
Settings is now yours alone: your theme and your subscriptions.
|
||||||
|
- A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
|
||||||
|
folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
|
||||||
|
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
|
||||||
|
- The theme is chosen in Settings only; the button beside the iPodderX name is gone.
|
||||||
|
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
|
||||||
|
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
|
||||||
|
them, where long notes left them looking missing.
|
||||||
|
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
|
||||||
|
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
|
||||||
|
`web/src`, type-checked, and built with swc; building ipx needs node.
|
||||||
|
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
|
||||||
|
between visits and fetches it again only when an update changes it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Switching tabs straight after marking everything read no longer shows the previous tab's
|
||||||
|
items: of two lists asked for at once, only the later one is shown.
|
||||||
|
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
|
||||||
|
their own, and on white for an iPhone's home screen.
|
||||||
|
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
|
||||||
|
higher than a downloaded one's.
|
||||||
|
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
|
||||||
|
now show: ipx asks for them without saying it is the page showing them.
|
||||||
|
- While an episode plays, its play buttons in the files pane, its row and the toolbar show
|
||||||
|
pause, as the player bar's does, and pause it when pressed.
|
||||||
|
- An item you open stays read. A list refresh that crossed with marking it read could put its
|
||||||
|
unread dot back until the next refresh.
|
||||||
|
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
|
||||||
|
one, rather than a few read items lingering until a refresh cleared them.
|
||||||
|
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
|
||||||
|
leaves it out of their page.
|
||||||
|
- An image or link in a post given relative to the post, such as The Observation Deck's, now
|
||||||
|
points at the post's site rather than at ipx, and shows.
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-09-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Time left, and when an episode counts as finished, go by the length your player measured
|
||||||
|
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
|
||||||
|
- A player left open in another tab or on another device no longer saves its older place over
|
||||||
|
where you have got to since, which could drop an episode out of Currently Listening.
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Each episode in Currently Listening has a cross that takes it off the list. It forgets where you
|
||||||
|
got to, so playing it again starts from the beginning.
|
||||||
|
- An admin can give a feed a Directory category in its settings (`category` in config.toml), for
|
||||||
|
the blogs and other feeds that name none of their own. A feed's own iTunes category still wins.
|
||||||
|
- Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds,
|
||||||
|
g and a letter to go to a place, o to play, s to pin, and more. Press ? for the whole list.
|
||||||
|
- Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
|
||||||
|
of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
|
||||||
|
both filter in place.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Currently Listening marks the episode in the player with the EQ bars, as the item list does,
|
||||||
|
and its progress and time left move as it plays. Each row says how much is left.
|
||||||
|
- Directory shows each feed as its cover art in a grid, title and subscriber count underneath,
|
||||||
|
instead of a list. Popular and the Add a feed dialog keep their rows.
|
||||||
|
- The pages are set in Inter, served by ipx itself. Classic keeps Lucida Grande.
|
||||||
|
- Keeping an item is now pinning it: a thumbtack in place of the flag, and Pin, Pinned and Unpin
|
||||||
|
in place of Keep, Kept and Stop keeping. A pinned item is still never deleted.
|
||||||
|
- Currently Listening is its own place in the feed list, below Popular, instead of a section at
|
||||||
|
the bottom of the Popular page.
|
||||||
|
- The first scan after upgrading fetches every feed in full once, on its usual schedule, so each
|
||||||
|
picks up its category without waiting for the publisher to change something.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- An episode that fails to load, or is paused before it has, no longer forgets where you left off
|
||||||
|
in it, and so no longer drops out of Currently Listening.
|
||||||
|
- Currently Listening lists the episodes you have started. It left out anything marked read, and
|
||||||
|
opening an episode marks it read, so it usually showed nothing. An episode now leaves the list
|
||||||
|
once 90% of it has played.
|
||||||
|
- A WordPress post that embeds the file it encloses no longer lists, and downloads, that file
|
||||||
|
twice. Items that already had it twice are folded into one at startup, and the spare copy
|
||||||
|
deleted.
|
||||||
|
- The pinned column's heading lines up with the pins under it, and every heading sits a pixel
|
||||||
|
further right, over its column.
|
||||||
|
- The feeds left behind by an OPML subscription removed before ipx retired them are cleared at
|
||||||
|
startup: forgotten if nothing was downloaded, kept as orphaned if something was. Feeds from it
|
||||||
|
that have since been given their own settings stay as they are, with their items. Removing an
|
||||||
|
OPML or Patreon subscription no longer deletes the items of a feed inside it that has its own
|
||||||
|
settings.
|
||||||
|
- Titles that arrive as HTML, such as The Verge's, no longer show their entities as text:
|
||||||
|
"Meta’s" reads "Meta’s". Titles already stored are corrected the next time their feed
|
||||||
|
changes.
|
||||||
|
|
||||||
|
## [0.5.5] - 2026-09-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A feed whose site sends a message instead of the feed, such as "Unable to establish a DB
|
||||||
|
connection", now shows that message and is flagged as the publisher's problem, instead of two
|
||||||
|
parser errors about reaching the end of input.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- An unused icon glyph (`minus`) left over from before Unsubscribe settled on `circleMinus`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Add a feed opened over Directory or Popular now shows its Popular list instead of staying on
|
||||||
|
"Loading…", and no longer cuts the Directory behind it down to ten.
|
||||||
|
|
||||||
## [0.5.4] - 2026-09-14
|
## [0.5.4] - 2026-09-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -355,7 +502,12 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...main
|
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
||||||
|
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||||
|
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
||||||
|
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
|
||||||
|
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
|
||||||
|
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
|
||||||
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
|
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
|
||||||
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
|
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
|
||||||
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
|
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
|
||||||
|
|||||||
84
CLAUDE.md
84
CLAUDE.md
@@ -13,12 +13,16 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
|
|||||||
| | Host | In the container |
|
| | Host | In the container |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
||||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
|
| Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||||
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||||
|
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
||||||
|
|
||||||
|
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
||||||
|
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
||||||
|
|
||||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -58,18 +62,34 @@ the shell running the command and kills the session (exit 144). This has happene
|
|||||||
|
|
||||||
## Before you touch the page
|
## Before you touch the page
|
||||||
|
|
||||||
`web/index.html` is `include_str!`d into the binary, so **every page change needs a rebuild** before
|
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
||||||
it is visible. It is one file: markup, CSS and script.
|
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
||||||
|
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
||||||
|
make up each page's script. `build.rs` runs
|
||||||
|
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
|
||||||
|
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
|
||||||
|
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
|
||||||
|
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
|
||||||
|
deploy changes them and their names. So **every page change needs a
|
||||||
|
rebuild** before it is visible, and building needs node and `npm ci` run once.
|
||||||
|
|
||||||
|
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
||||||
|
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
||||||
|
file goes into that list. Top-level names are kept as they are, because markup calls some by
|
||||||
|
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
|
||||||
|
|
||||||
After any edit to it:
|
After any edit to it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npx tsc -p .
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
```
|
```
|
||||||
|
|
||||||
That loads the script against a stub DOM and checks every selector it wires at load actually
|
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
|
||||||
exists. It exists because a patch once anchored on a deleted function, `String.replace` silently
|
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
||||||
matched nothing, and the whole UI died with a `ReferenceError` while every server-side test passed.
|
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
||||||
|
function, `String.replace` silently matched nothing, and the whole UI died with a
|
||||||
|
`ReferenceError` while every server-side test passed.
|
||||||
|
|
||||||
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
||||||
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
||||||
@@ -77,9 +97,10 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test # ~51 tests: parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
|
npx tsc -p . # type-checks web/src
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
npx playwright test # 16 browser tests against a real daemon on fixture feeds
|
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
Things about the browser suite that have cost time:
|
Things about the browser suite that have cost time:
|
||||||
@@ -105,10 +126,18 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
|
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
|
||||||
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
||||||
and `position` columns from before accounts; two bugs came from queries still reading them
|
and `position` columns from before accounts; two bugs came from queries still reading them
|
||||||
(retention, and the entry pruner), and `migrate()` now drops them.
|
(retention, and the entry pruner), and they were dropped in 0.5.
|
||||||
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once;
|
* **The catalogue and the server settings are in the database, not config.toml** (issue #18):
|
||||||
|
tables `catalogue` (each feed's `config::Feed` as JSON) and `settings` (`general`:
|
||||||
|
`config::Stored`). ipx still runs from one in-memory `Config`, config.toml for where things are
|
||||||
|
and who gets in, the database for the rest (`assemble_config`); a change goes through
|
||||||
|
`Ctx::store_cfg`, never a write to the file. The first start on a database without them imports
|
||||||
|
config.toml's and trims the file, keeping `config.toml.pre-database`. A feed exists once;
|
||||||
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
||||||
derived and never written to config.
|
derived and never in the catalogue.
|
||||||
|
* **Postgres connections ask for no notices** (`client_min_messages=warning`, `db::url_for`).
|
||||||
|
Postgres sends one for every `CREATE ... IF NOT EXISTS` on something existing, sqlx logs each,
|
||||||
|
and tracing-subscriber's per-layer filters then dropped the next line ipx logged.
|
||||||
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
||||||
Anyone wanting an item is enough to fetch it.
|
Anyone wanting an item is enough to fetch it.
|
||||||
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
||||||
@@ -120,10 +149,16 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
||||||
different task. `ipx status` checks the control socket and the database; to see the worker
|
different task. `ipx status` checks the control socket and the database; to see the worker
|
||||||
getting through its jobs, watch for `scan complete` in the log.
|
getting through its jobs, watch for `scan complete` in the log.
|
||||||
* **Every `ipx` command runs `migrate()` when it opens the database**, the healthcheck's
|
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
|
||||||
`ipx status` included. A migration that rewrites a big table (`DROP COLUMN`) takes seconds on
|
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
||||||
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait
|
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
||||||
for `daemon started` in the log. Copy `state.db` aside before deploying one.
|
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
||||||
|
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
||||||
|
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you.
|
||||||
|
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
||||||
|
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
|
||||||
|
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
|
||||||
|
1; no `rowid`, `GLOB` or `UPDATE OR IGNORE`. `Args` in `db.rs` builds the parameters.
|
||||||
|
|
||||||
## House style
|
## House style
|
||||||
|
|
||||||
@@ -134,9 +169,9 @@ addressed to the person using it.
|
|||||||
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||||
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
||||||
say, such as what was wrong before or what it cost to find out, write it up at the top of
|
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
|
||||||
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more
|
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
||||||
than once.
|
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
||||||
|
|
||||||
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
||||||
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
||||||
@@ -151,3 +186,14 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
|||||||
(documented in [docs/sso.md](docs/sso.md)).
|
(documented in [docs/sso.md](docs/sso.md)).
|
||||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
||||||
Substack-style subtitles.
|
Substack-style subtitles.
|
||||||
|
|
||||||
|
<!-- rtk-instructions v2 -->
|
||||||
|
# Command output
|
||||||
|
|
||||||
|
Command output here is condensed to save tokens, keeping every signal and
|
||||||
|
dropping costly noise. Treat it as the complete result: run commands
|
||||||
|
normally, and batch related commands into one call to avoid extra turns.
|
||||||
|
Truncated results state their recovery path in their own output. Re-run a
|
||||||
|
command as `rtk proxy <cmd>` only when its result is unusable: empty when
|
||||||
|
output was clearly expected, contradicting its exit code, or garbled.
|
||||||
|
<!-- /rtk-instructions -->
|
||||||
875
Cargo.lock
generated
875
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.5.4"
|
version = "0.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -15,10 +15,10 @@ futures-util = { version = "0.3.34", default-features = false, features = ["std"
|
|||||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
quick-xml = "0.42.0"
|
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
||||||
rss = "2.1.1"
|
rss = "2.1.1"
|
||||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
||||||
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
||||||
|
# build.rs builds the web pages from TypeScript with swc, which needs node.
|
||||||
FROM rust:1-slim-bookworm AS build
|
FROM rust:1-slim-bookworm AS build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential nodejs npm \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# swc only: Playwright and TypeScript are for testing and type-checking, not for building.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Dependencies first, so editing the source does not rebuild librqbit every time.
|
# Dependencies first, so editing the source does not rebuild librqbit every time.
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
||||||
&& cargo build --release --locked \
|
&& cargo build --release --locked \
|
||||||
&& rm -rf src
|
&& rm -rf src
|
||||||
|
|
||||||
|
COPY build.rs ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
|
- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
|
||||||
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
|
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
|
||||||
comes in Dark, Light and Classic themes, and works on a phone.
|
comes in Dark, Light and Classic themes, and works on a phone.
|
||||||
- **Several people, one copy.** Each person has their own subscriptions and their own read, kept
|
- **Several people, one copy.** Each person has their own subscriptions and their own read, pinned
|
||||||
and playback state. There is one file on disk per episode, however many people want it. People
|
and playback state. There is one file on disk per episode, however many people want it. People
|
||||||
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
||||||
manage accounts and settings.
|
manage accounts and settings.
|
||||||
@@ -21,7 +21,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
on new downloads per scan.
|
on new downloads per scan.
|
||||||
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
||||||
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
|
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
|
||||||
an item someone has kept.
|
an item someone has pinned.
|
||||||
- **OPML.** You can import and export your own subscriptions. You can also subscribe to an OPML
|
- **OPML.** You can import and export your own subscriptions. You can also subscribe to an OPML
|
||||||
URL, which keeps a whole list in step as a folder.
|
URL, which keeps a whole list in step as a folder.
|
||||||
|
|
||||||
@@ -63,7 +63,6 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
||||||
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
||||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
||||||
| [docs/history.md](docs/history.md) | How it was built, with what was wrong and why |
|
|
||||||
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|||||||
57
TODO.md
57
TODO.md
@@ -1,57 +0,0 @@
|
|||||||
# To do
|
|
||||||
|
|
||||||
## Errors in the log
|
|
||||||
|
|
||||||
From the production log and the feeds' stored errors on 2026-09-13. The Docker log only reached back
|
|
||||||
to 12:32 UTC, so the list comes from `feeds.last_error`: 57 of 1,059 feeds. None of it is ipx's
|
|
||||||
User-Agent; a browser gets the same answers.
|
|
||||||
|
|
||||||
- [x] **Stop scanning an OPML's feeds once nobody subscribes to it.** 55 of the 57 are feeds from
|
|
||||||
`davewiner` (lists.opml.org/davefeeds.xml). The list left `config.toml` about 14 hours before this
|
|
||||||
was written, but its 922 feeds are still in the database and still scanned every hour, with
|
|
||||||
auto-download on: `subscriptions()` adds every derived feed, and with no parent to copy from,
|
|
||||||
`auto_download` falls back to `true` (`src/main.rs:937`). `docs/users.md` says a feed stops being
|
|
||||||
scanned when its last subscriber leaves. Skip a derived feed whose parent is not in config, and
|
|
||||||
when the last subscriber leaves an OPML, treat its feeds the way `sync_group` treats ones the list
|
|
||||||
dropped: remove those with nothing downloaded, mark the rest orphaned. `remove_feed` and `ipx rm`
|
|
||||||
both leave them behind today. (`src/main.rs`, `src/web.rs`)
|
|
||||||
- [x] **Read feeds with a bare `&`.** kcpw has `<link>https://kcpw.org/?post_type=post&p=125715</link>`,
|
|
||||||
and both feedland feeds have the same fault. Strict XML refuses them; browsers and other readers do
|
|
||||||
not. When `feed::parse` fails, try once more with every `&` that does not start an entity written
|
|
||||||
as `&`. Nobody subscribes to these three now, but the next feed like them will fail the same
|
|
||||||
way. (`src/feed.rs`)
|
|
||||||
- [x] **Say what came back when it is not a feed.** Thirteen errors read "not RSS (the input did not
|
|
||||||
begin with an rss tag) and not Atom (...)". Each one checked was a web page: the feed moved and its
|
|
||||||
old URL redirects to the site, or the domain lapsed. Say "got a web page, not a feed", and when the
|
|
||||||
page links a feed (`<link rel="alternate" type="application/rss+xml">`), name it. That link found
|
|
||||||
the new feed for om.co, ms.now, Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and
|
|
||||||
Daily Kos. A `202` with an empty body (British Antarctic Survey) should read as "nothing yet", not
|
|
||||||
as a parse failure. (`src/feed.rs`)
|
|
||||||
- [x] **Show a publisher's error in the UI.** Today a failing feed shows its raw error in red only
|
|
||||||
once you open it (`web/index.html:877`, `:960`); the OPML view marks a failing child "error"
|
|
||||||
(`:988`), and the sidebar shows nothing. Mark a failing feed in the sidebar too, and say whose
|
|
||||||
problem it is and what to do, in plain words: a 404 means the publisher took the feed down or moved
|
|
||||||
it, 401 and 403 that the site refuses ipx, 402 that the feed needs a paid plan, a name that no
|
|
||||||
longer resolves that the site is gone, and a web page instead of a feed that it moved (with the new
|
|
||||||
URL when the page links one). Offer Unsubscribe, or Use the new address. Keep timeouts, 5xx and
|
|
||||||
429 out of it, and flag a feed only once it has failed for a day: macmanx failed once and read
|
|
||||||
fine an hour later. That needs the time a feed started failing, kept beside `last_error` and
|
|
||||||
cleared when a check succeeds. (`web/index.html`, `src/db.rs`, `src/main.rs`)
|
|
||||||
- [ ] **Westenberg.** The only failing feed anyone subscribes to. The Hacker News stars list still
|
|
||||||
gives `joanwestenberg.com/rss`, which is a 404; the feed is now `joanwestenberg.com/feed`.
|
|
||||||
Nothing for ipx to fix; subscribe to the new URL directly.
|
|
||||||
|
|
||||||
## Other Fixes and Features
|
|
||||||
|
|
||||||
- [x] Remember which feed is selected and view (all, unread, flagged, etc) user as selected between visits. If unknown default to All Subscriptions
|
|
||||||
- [x] When clicking any link it should open in a new tab
|
|
||||||
- [x] In mobile (iOS) sometimes the top line items like the hamburger menu are not clickable unless you do a hard refresh — likely fixed (100dvh instead of 100vh), unverified on a real device; reopen if it still happens
|
|
||||||
- [x] video files play as audio files, they should play as video.
|
|
||||||
- [x] Move Light/Dark/Classic options to user settings. Include an Auto mode that uses system preferences for light/dark modes
|
|
||||||
- [x] Below Popular, have a currently listening section to show what podcasts have been started and not finnished
|
|
||||||
- [x] Update subscribe/unsubscribe icons to be circle-minus (unsubscribe) and circle-check (subscribe)
|
|
||||||
- [x] If I'm on the Unread tab, and I click to read an item the entry in the list will disappear. it should remain until I click to another item.
|
|
||||||
|
|
||||||
## Directory Overhaul
|
|
||||||
|
|
||||||
- [ ] Directory needs to be more functional, with categories and a more interesting layout. use /frontend-design to help
|
|
||||||
14
build.rs
Normal file
14
build.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR,
|
||||||
|
//! where src/web.rs include_str!s them. Needs node and `npm ci` run first.
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=web");
|
||||||
|
println!("cargo:rerun-if-changed=package-lock.json");
|
||||||
|
let out = std::env::var("OUT_DIR").unwrap();
|
||||||
|
let status = Command::new("node")
|
||||||
|
.args(["web/build.mjs", &out])
|
||||||
|
.status()
|
||||||
|
.expect("building the web pages needs node on PATH (and `npm ci` run once)");
|
||||||
|
assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why");
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ services:
|
|||||||
PGID: "100"
|
PGID: "100"
|
||||||
TZ: "America/Toronto"
|
TZ: "America/Toronto"
|
||||||
IPX_LOG: "ipx=info"
|
IPX_LOG: "ipx=info"
|
||||||
|
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
|
||||||
|
env_file:
|
||||||
|
- ipodderx.env # relative: Arcane resolves it inside its own container
|
||||||
ports:
|
ports:
|
||||||
- "8099:8099" # web UI
|
- "8099:8099" # web UI
|
||||||
- "6881:6881/tcp" # BitTorrent peers
|
- "6881:6881/tcp" # BitTorrent peers
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ it to a running daemon.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
||||||
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
||||||
| `src/db.rs` | SQLite schema, migrations, every query | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||||
|
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
||||||
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
||||||
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
||||||
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
||||||
@@ -19,9 +20,15 @@ it to a running daemon.
|
|||||||
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
||||||
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
||||||
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
||||||
| `web/index.html` | The whole front end, `include_str!`d into the binary | — |
|
| `web/index.html` | The app's markup | — |
|
||||||
|
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
||||||
|
| `web/app.css` | The stylesheet both pages share | — |
|
||||||
|
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
||||||
|
| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
|
||||||
|
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
|
||||||
|
|
||||||
The page is compiled in, so **editing `web/index.html` needs a rebuild**.
|
The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a
|
||||||
|
build needs node and `npm ci` run once.
|
||||||
|
|
||||||
## A scan
|
## A scan
|
||||||
|
|
||||||
@@ -59,14 +66,14 @@ entry_state user_id, feed_id, guid, read, flagged, position
|
|||||||
```
|
```
|
||||||
|
|
||||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
||||||
before accounts; two bugs came from queries still reading them, and `migrate()` drops them from an
|
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
||||||
older database.
|
|
||||||
|
|
||||||
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
|
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
|
||||||
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a
|
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
||||||
retired one in its `retired` list; both are checked with `PRAGMA table_info`. Columns from before
|
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
||||||
0.3.0, the oldest version an upgrade may start from, need no entry. `Db::memory()` runs the same
|
`create_missing`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
|
||||||
path as `Db::open`, so a migration cannot pass the tests while missing in production.
|
database the same way, so the tests run on the schema production gets. A database from before
|
||||||
|
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
|
||||||
|
|
||||||
## Control socket
|
## Control socket
|
||||||
|
|
||||||
@@ -119,7 +126,7 @@ else a `401`.
|
|||||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
| `POST /api/fetch` | |
|
| `POST /api/fetch` | |
|
||||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
|
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id |
|
||||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take
|
Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
|
||||||
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
|
||||||
|
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
|
||||||
|
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
|
||||||
|
`media_types`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
|
||||||
|
take effect without a restart.
|
||||||
|
|
||||||
|
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
|
||||||
|
settings from config.toml, then rewrites config.toml without them, keeping the original beside it
|
||||||
|
as `config.toml.pre-database`. After that, feeds or those settings written into config.toml are
|
||||||
|
ignored, with a warning in the log saying so. The sections below describe them as they were
|
||||||
|
written in config.toml, which is still how a fresh install begins.
|
||||||
|
|
||||||
|
config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
|
||||||
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
|
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
|
||||||
|
|
||||||
| What | Where | Override |
|
| What | Where | Override |
|
||||||
@@ -11,8 +23,10 @@ effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
|||||||
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
|
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
|
||||||
| Downloads | `[general] download_dir` | — |
|
| Downloads | `[general] download_dir` | — |
|
||||||
|
|
||||||
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db`
|
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
|
||||||
while the daemon is stopped, or with `sqlite3 state.db .backup`.
|
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
||||||
|
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`. `ipx copy-db <state.db>` copies a
|
||||||
|
SQLite database into the empty Postgres one `IPX_DATABASE_URL` names.
|
||||||
|
|
||||||
## `[general]`
|
## `[general]`
|
||||||
|
|
||||||
@@ -28,6 +42,9 @@ max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
|||||||
media_types = ["audio", "video"]
|
media_types = ["audio", "video"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check` and `media_types` move into the
|
||||||
|
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
||||||
|
|
||||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||||
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
|
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
|
||||||
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
||||||
@@ -88,8 +105,9 @@ itself carry a credential. Put TLS in front of it if that matters.
|
|||||||
|
|
||||||
## `[feeds.<id>]`
|
## `[feeds.<id>]`
|
||||||
|
|
||||||
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add`
|
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
||||||
derives it from the feed title.
|
feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable,
|
||||||
|
human-readable, and used in paths and the API. `ipx add` derives it from the feed title.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[feeds.atp]
|
[feeds.atp]
|
||||||
@@ -97,6 +115,7 @@ url = "https://atp.fm/rss"
|
|||||||
folder = "Accidental Tech Podcast" # default: the feed title
|
folder = "Accidental Tech Podcast" # default: the feed title
|
||||||
schedule = "every 6h" # overrides [general] for this feed
|
schedule = "every 6h" # overrides [general] for this feed
|
||||||
media_types = ["audio"] # overrides [general] for this feed
|
media_types = ["audio"] # overrides [general] for this feed
|
||||||
|
category = "Technology" # the Directory's, if the feed names none
|
||||||
username = "ray" # HTTP basic auth
|
username = "ray" # HTTP basic auth
|
||||||
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
||||||
```
|
```
|
||||||
@@ -106,8 +125,8 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
|
|||||||
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
||||||
and are the same for everyone. See [users.md](users.md).
|
and are the same for everyone. See [users.md](users.md).
|
||||||
|
|
||||||
Feeds derived from a subscribed OPML are **not** written here: the OPML is the source of truth and
|
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||||
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry.
|
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -115,6 +134,8 @@ they are re-derived on every scan. Editing one in the UI promotes it to a real c
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `IPX_CONFIG` | Config file path |
|
| `IPX_CONFIG` | Config file path |
|
||||||
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
||||||
|
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
||||||
|
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
||||||
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
||||||
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
||||||
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
||||||
|
|||||||
1670
docs/history.md
1670
docs/history.md
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ fetch, one parse and one file.
|
|||||||
|
|
||||||
| Yours alone | The same for everyone |
|
| Yours alone | The same for everyone |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Read, kept, playback position | The feed's URL |
|
| Read, pinned, playback position | The feed's URL |
|
||||||
| Which feeds you see at all | Its download folder |
|
| Which feeds you see at all | Its download folder |
|
||||||
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
|
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
|
||||||
| | The file on disk |
|
| | The file on disk |
|
||||||
@@ -34,10 +34,10 @@ is shared.
|
|||||||
|
|
||||||
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
|
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
|
||||||
for everyone** and names them in the confirmation, and the server has the last word: if anyone else
|
for everyone** and names them in the confirmation, and the server has the last word: if anyone else
|
||||||
has kept the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
|
has pinned the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
|
||||||
reason, and only `?force=true` goes through.
|
reason, and only `?force=true` goes through.
|
||||||
|
|
||||||
Retention follows the same rule: an item anyone kept keeps its file, and it counts as read only once
|
Retention follows the same rule: an item anyone pinned keeps its file, and it counts as read only once
|
||||||
every subscriber has read it.
|
every subscriber has read it.
|
||||||
|
|
||||||
## Signing in
|
## Signing in
|
||||||
@@ -72,7 +72,9 @@ re-subscribing does not pull the back catalogue again.
|
|||||||
|
|
||||||
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
|
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
|
||||||
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
|
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
|
||||||
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
|
included. Directory shows every one of them A to Z as a grid of cover art. Above it, a filter
|
||||||
|
picks Podcasts (anything with audio or video) or Blogs (the rest), and chips pick the category
|
||||||
|
each show gives itself in iTunes; the two combine. Your own feeds are marked Subscribed.
|
||||||
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
||||||
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
||||||
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
||||||
|
|||||||
887
package-lock.json
generated
887
package-lock.json
generated
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/core": "^1.16.2",
|
||||||
|
"@swc/html": "^1.16.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0"
|
"@playwright/test": "^1.56.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
@@ -25,6 +30,847 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/core": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3",
|
||||||
|
"@swc/types": "^0.1.28"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/swc"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/core-darwin-arm64": "1.16.2",
|
||||||
|
"@swc/core-darwin-x64": "1.16.2",
|
||||||
|
"@swc/core-linux-arm-gnueabihf": "1.16.2",
|
||||||
|
"@swc/core-linux-arm64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-arm64-musl": "1.16.2",
|
||||||
|
"@swc/core-linux-ppc64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-s390x-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-x64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-x64-musl": "1.16.2",
|
||||||
|
"@swc/core-win32-arm64-msvc": "1.16.2",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.16.2",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.16.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/helpers": ">=0.5.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/helpers": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-x64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-x64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/counter": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html/-/html-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/html-darwin-arm64": "1.16.2",
|
||||||
|
"@swc/html-darwin-x64": "1.16.2",
|
||||||
|
"@swc/html-linux-arm-gnueabihf": "1.16.2",
|
||||||
|
"@swc/html-linux-arm64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-arm64-musl": "1.16.2",
|
||||||
|
"@swc/html-linux-ppc64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-s390x-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-x64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-x64-musl": "1.16.2",
|
||||||
|
"@swc/html-win32-arm64-msvc": "1.16.2",
|
||||||
|
"@swc/html-win32-ia32-msvc": "1.16.2",
|
||||||
|
"@swc/html-win32-x64-msvc": "1.16.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-darwin-arm64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-darwin-x64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-ppc64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-s390x-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-x64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-x64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-arm64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-ia32-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-x64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/types": {
|
||||||
|
"version": "0.1.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
|
||||||
|
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
@@ -53,6 +899,41 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -1,13 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.",
|
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "node web/build.mjs",
|
||||||
|
"typecheck": "tsc -p .",
|
||||||
|
"smoke": "node tests/page-smoke.js",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:headed": "playwright test --headed",
|
"test:headed": "playwright test --headed"
|
||||||
"smoke": "node tests/page-smoke.js"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0"
|
"@playwright/test": "^1.56.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/core": "^1.16.2",
|
||||||
|
"@swc/html": "^1.16.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
skills-lock.json
Normal file
11
skills-lock.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"security-audit": {
|
||||||
|
"source": "cloudflare/security-audit-skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/security-audit/SKILL.md",
|
||||||
|
"computedHash": "98b97aad2873b3b9a8e064007c25e7827b493ba27fda8bfbdb41dab586767973"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/config.rs
128
src/config.rs
@@ -115,6 +115,10 @@ pub struct Feed {
|
|||||||
/// Download folder name; defaults to the sanitized feed title.
|
/// Download folder name; defaults to the sanitized feed title.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub folder: Option<String>,
|
pub folder: Option<String>,
|
||||||
|
/// The Directory's category for a feed that names none of its own, as most blogs do not.
|
||||||
|
/// The feed's own iTunes category wins where there is one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub category: Option<String>,
|
||||||
/// Every whitespace-separated word of a keyword must appear in the
|
/// Every whitespace-separated word of a keyword must appear in the
|
||||||
/// url/title/description/categories for an enclosure to be taken.
|
/// url/title/description/categories for an enclosure to be taken.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
@@ -257,21 +261,86 @@ impl Config {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, path: &Path) -> Result<()> {
|
}
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
std::fs::create_dir_all(dir)
|
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||||
.with_context(|| format!("creating {}", dir.display()))?;
|
/// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
|
||||||
|
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
|
||||||
|
/// or decides who gets in, and stays in config.toml.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Stored {
|
||||||
|
pub schedule: String,
|
||||||
|
pub max_total_gb: f64,
|
||||||
|
pub max_age_days: u64,
|
||||||
|
pub max_new_per_check: usize,
|
||||||
|
pub media_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[general]` keys that live in the database once it holds the configuration.
|
||||||
|
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
|
||||||
|
|
||||||
|
impl Stored {
|
||||||
|
pub fn of(cfg: &Config) -> Self {
|
||||||
|
let g = &cfg.general;
|
||||||
|
Self {
|
||||||
|
schedule: g.schedule.clone(),
|
||||||
|
max_total_gb: g.max_total_gb,
|
||||||
|
max_age_days: g.max_age_days,
|
||||||
|
max_new_per_check: g.max_new_per_check,
|
||||||
|
media_types: g.media_types.clone(),
|
||||||
}
|
}
|
||||||
let text = toml::to_string_pretty(self)?;
|
|
||||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
|
||||||
// Passwords may live in here.
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn apply(self, cfg: &mut Config) {
|
||||||
|
let g = &mut cfg.general;
|
||||||
|
g.schedule = self.schedule;
|
||||||
|
g.max_total_gb = self.max_total_gb;
|
||||||
|
g.max_age_days = self.max_age_days;
|
||||||
|
g.max_new_per_check = self.max_new_per_check;
|
||||||
|
g.media_types = self.media_types;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// config.toml as it is kept once the database holds the feeds and server settings: the same
|
||||||
|
/// file without `[feeds]` or the `[general]` keys in `Stored`.
|
||||||
|
pub fn save_bootstrap(&self, path: &Path) -> Result<()> {
|
||||||
|
let mut v = toml::Value::try_from(self)?;
|
||||||
|
if let Some(t) = v.as_table_mut() {
|
||||||
|
t.remove("feeds");
|
||||||
|
if let Some(g) = t.get_mut("general").and_then(|g| g.as_table_mut()) {
|
||||||
|
for k in STORED_KEYS {
|
||||||
|
g.remove(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_private(path, &toml::to_string_pretty(&v)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether config.toml still lists feeds or server settings, which the database now holds:
|
||||||
|
/// an edit there would otherwise go unnoticed.
|
||||||
|
pub fn file_holds_stored(path: &Path) -> bool {
|
||||||
|
let Ok(text) = std::fs::read_to_string(path) else { return false };
|
||||||
|
let Ok(v) = text.parse::<toml::Table>() else { return false };
|
||||||
|
v.get("feeds").and_then(|f| f.as_table()).is_some_and(|f| !f.is_empty())
|
||||||
|
|| v.get("general")
|
||||||
|
.and_then(|g| g.as_table())
|
||||||
|
.is_some_and(|g| STORED_KEYS.iter().any(|k| g.contains_key(*k)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a config file readable by its owner alone: feed passwords have lived in it.
|
||||||
|
fn write_private(path: &Path, text: &str) -> Result<()> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
||||||
@@ -366,6 +435,36 @@ fn expand_tilde(p: &Path) -> PathBuf {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
|
||||||
|
let cfg: Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
download_dir = "/downloads"
|
||||||
|
schedule = "every 2h"
|
||||||
|
max_new_per_check = 7
|
||||||
|
media_types = ["audio"]
|
||||||
|
[web]
|
||||||
|
bind = "0.0.0.0:8099"
|
||||||
|
token = "t"
|
||||||
|
[feeds.show]
|
||||||
|
url = "http://x/show.xml"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let path = std::env::temp_dir().join(format!("ipx-bootstrap-{}.toml", std::process::id()));
|
||||||
|
std::fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
|
||||||
|
assert!(Config::file_holds_stored(&path), "a whole config.toml holds them");
|
||||||
|
cfg.save_bootstrap(&path).unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(!Config::file_holds_stored(&path), "{text}");
|
||||||
|
let back: Config = toml::from_str(&text).unwrap();
|
||||||
|
assert!(back.feeds.is_empty());
|
||||||
|
assert_eq!(back.web.token, "t", "who may sign in stays in the file");
|
||||||
|
assert_eq!(back.general.download_dir, PathBuf::from("/downloads"), "where things are, too");
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_a_config_and_applies_defaults() {
|
fn parses_a_config_and_applies_defaults() {
|
||||||
let cfg: Config = toml::from_str(
|
let cfg: Config = toml::from_str(
|
||||||
@@ -469,7 +568,7 @@ mod tests {
|
|||||||
taken.insert("the-daily".to_string(), Feed {
|
taken.insert("the-daily".to_string(), Feed {
|
||||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||||
auto_download: true, max_new_per_check: None, username: None,
|
auto_download: true, max_new_per_check: None, username: None,
|
||||||
password: None, password_env: None,
|
password: None, password_env: None, category: None,
|
||||||
});
|
});
|
||||||
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||||
}
|
}
|
||||||
@@ -489,6 +588,7 @@ mod tests {
|
|||||||
username: Some("ray".into()),
|
username: Some("ray".into()),
|
||||||
password: Some("literal".into()),
|
password: Some("literal".into()),
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
};
|
};
|
||||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||||
|
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ mod tests {
|
|||||||
let mut f = crate::config::Feed {
|
let mut f = crate::config::Feed {
|
||||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
||||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
||||||
max_new_per_check: None, username: None, password: None, password_env: None,
|
max_new_per_check: None, username: None, password: None, password_env: None, category: None,
|
||||||
};
|
};
|
||||||
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||||
|
|
||||||
|
|||||||
285
src/entity.rs
Normal file
285
src/entity.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//! The database's tables as SeaORM entities: the one description of the schema, from which
|
||||||
|
//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see
|
||||||
|
//! `db::create_missing`). Times are Unix seconds.
|
||||||
|
|
||||||
|
pub mod feeds {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "feeds")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub url: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// The channel's first <itunes:category>, for the Directory.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub category: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub etag: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
pub last_checked: Option<i64>,
|
||||||
|
pub ttl_mins: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
/// When the current run of failures began; NULL while the feed is healthy. Kept through
|
||||||
|
/// repeated failures so the UI can tell a blip (macmanx: failed once, fine an hour
|
||||||
|
/// later) from a feed that has been down for a day.
|
||||||
|
pub error_since: Option<i64>,
|
||||||
|
/// Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub orphaned: bool,
|
||||||
|
/// The OPML subscription this feed came from.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
/// Derived from an OPML and not written to config.toml. Writing 80-odd generated entries
|
||||||
|
/// into a hand-edited file made it unreadable; the OPML is the source of truth, so they
|
||||||
|
/// are re-derived instead. Customising one promotes it to config.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub managed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod entries {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "entries")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub published: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub first_seen: i64,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
pub episode: Option<i64>,
|
||||||
|
pub season: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod enclosures {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "enclosures")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
/// The dedupe key, and the reason one file serves every subscriber. A reaped file keeps
|
||||||
|
/// its row with path NULL and state 'reaped', so a purged episode is never fetched again.
|
||||||
|
#[sea_orm(unique, column_type = "Text")]
|
||||||
|
pub url: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub mime: Option<String>,
|
||||||
|
pub length: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub state: String,
|
||||||
|
#[sea_orm(default_value = 0)]
|
||||||
|
pub bytes_done: i64,
|
||||||
|
pub downloaded_at: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod users {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
/// Unique without regard to case: `db::create_missing` adds the index on lower(name), which
|
||||||
|
/// works the same on both databases where SQLite's COLLATE NOCASE does not.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
/// NULL for someone who only ever arrives through the proxy: there is no password to
|
||||||
|
/// check, and leaving it empty is not the same as leaving it unset.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub pass_hash: Option<String>,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub is_admin: bool,
|
||||||
|
/// For whoever maintains the server. NULL where it is not known.
|
||||||
|
pub created: Option<i64>,
|
||||||
|
pub last_login: Option<i64>,
|
||||||
|
/// The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub theme: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub theme_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table of one person's rows, gone when they are.
|
||||||
|
macro_rules! owned_by_user {
|
||||||
|
() => {
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one person wants from a feed. The feed, its items and its files are shared; this is the
|
||||||
|
/// part that is not. NULL in a column means: follow the feed's own setting.
|
||||||
|
pub mod subscriptions {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "subscriptions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
/// JSON array of strings; NULL follows the feed.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub keywords: Option<String>,
|
||||||
|
pub auto_download: Option<bool>,
|
||||||
|
pub allow_explicit: Option<bool>,
|
||||||
|
pub max_new_per_check: Option<i64>,
|
||||||
|
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read, kept and how far in. One row per person per item, created on first touch; an item
|
||||||
|
/// nobody has touched has no row at all, which is what unread means.
|
||||||
|
pub mod entry_state {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "entry_state")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub read: bool,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub flagged: bool,
|
||||||
|
#[sea_orm(default_value = 0)]
|
||||||
|
pub position: i64,
|
||||||
|
/// The length this person's player measured, beside the position it is measured against.
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod sessions {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "sessions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub seen: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
||||||
|
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
||||||
|
pub mod catalogue {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "catalogue")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub spec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
|
||||||
|
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
|
||||||
|
/// configuration has moved in (issue #18).
|
||||||
|
pub mod settings {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "settings")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
165
src/feed.rs
165
src/feed.rs
@@ -11,6 +11,8 @@ pub struct ParsedFeed {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub ttl_mins: Option<u64>,
|
pub ttl_mins: Option<u64>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
|
/// The channel's first `<itunes:category>`, for the Directory's chips.
|
||||||
|
pub category: Option<String>,
|
||||||
pub entries: Vec<Entry>,
|
pub entries: Vec<Entry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ pub struct Failure {
|
|||||||
/// text already shown once a feed is open.
|
/// text already shown once a feed is open.
|
||||||
///
|
///
|
||||||
/// ponytail: matches on the fixed strings this crate itself produces (`anyhow!("HTTP
|
/// ponytail: matches on the fixed strings this crate itself produces (`anyhow!("HTTP
|
||||||
/// {status}")`, and the "got a web page" message above) plus the substrings a DNS failure
|
/// {status}")`, and `parse`'s "got a web page" and "the site sent") plus the substrings a DNS failure
|
||||||
/// reliably contains. Fragile if reqwest's own wording changes; the fallback is just showing
|
/// reliably contains. Fragile if reqwest's own wording changes; the fallback is just showing
|
||||||
/// nothing extra, so a miss costs a clearer message, not a wrong one.
|
/// nothing extra, so a miss costs a clearer message, not a wrong one.
|
||||||
pub fn explain_failure(msg: &str) -> Option<Failure> {
|
pub fn explain_failure(msg: &str) -> Option<Failure> {
|
||||||
@@ -112,6 +114,9 @@ pub fn explain_failure(msg: &str) -> Option<Failure> {
|
|||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
|
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
|
||||||
}
|
}
|
||||||
|
if msg.contains("the site sent ") {
|
||||||
|
return Some(Failure { reason: "The site sent a message instead of the feed; the publisher has to fix it.", new_url: None });
|
||||||
|
}
|
||||||
let low = msg.to_ascii_lowercase();
|
let low = msg.to_ascii_lowercase();
|
||||||
if low.contains("http 404") {
|
if low.contains("http 404") {
|
||||||
return Some(Failure { reason: "The publisher took this feed down, or moved it.", new_url: None });
|
return Some(Failure { reason: "The publisher took this feed down, or moved it.", new_url: None });
|
||||||
@@ -265,6 +270,9 @@ pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
|
|||||||
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
|
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
|
||||||
Ok(feed) => Ok(from_atom(feed)),
|
Ok(feed) => Ok(from_atom(feed)),
|
||||||
Err(atom_err) => {
|
Err(atom_err) => {
|
||||||
|
if let Some(said) = plain_text(bytes) {
|
||||||
|
return Err(anyhow!("the site sent {said} instead of a feed"));
|
||||||
|
}
|
||||||
// Some publishers (kcpw, feedland) write a bare "&" in a URL instead of
|
// Some publishers (kcpw, feedland) write a bare "&" in a URL instead of
|
||||||
// "&". Strict XML parsers refuse it; browsers don't. Retry once with
|
// "&". Strict XML parsers refuse it; browsers don't. Retry once with
|
||||||
// every offending "&" escaped rather than fail outright.
|
// every offending "&" escaped rather than fail outright.
|
||||||
@@ -297,6 +305,26 @@ fn looks_like_html(bytes: &[u8]) -> bool {
|
|||||||
head.contains("<!doctype html") || head.contains("<html")
|
head.contains("<!doctype html") || head.contains("<html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a site sent when it sent a sentence instead of markup. doghouse's feed answered 200 with
|
||||||
|
/// "Unable to establish a DB connection", and the two parsers' errors about end of input buried
|
||||||
|
/// it. Anything starting with `<` is markup, however broken, and keeps the parsers' errors.
|
||||||
|
fn plain_text(bytes: &[u8]) -> Option<String> {
|
||||||
|
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(512)]);
|
||||||
|
let text = head.trim_start_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
|
||||||
|
if text.starts_with('<') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let line = text.lines().next().unwrap_or("").trim_end();
|
||||||
|
if line.is_empty() {
|
||||||
|
return Some("an empty reply".into());
|
||||||
|
}
|
||||||
|
let mut said: String = line.chars().take(80).collect();
|
||||||
|
if said.len() < line.len() {
|
||||||
|
said.push('…');
|
||||||
|
}
|
||||||
|
Some(format!("\"{said}\""))
|
||||||
|
}
|
||||||
|
|
||||||
/// The feed a web page names as its own via `<link rel="alternate" type="application/rss+xml"
|
/// The feed a web page names as its own via `<link rel="alternate" type="application/rss+xml"
|
||||||
/// href="...">` (or the Atom equivalent) -- how the new address was found for om.co, ms.now,
|
/// href="...">` (or the Atom equivalent) -- how the new address was found for om.co, ms.now,
|
||||||
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
||||||
@@ -452,7 +480,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.filter_map(|(idx, item)| {
|
.filter_map(|(idx, item)| {
|
||||||
// Straight from the XML, so an item with several keeps all of them. Falls
|
// Straight from the XML, so an item with several keeps all of them. Falls
|
||||||
// back to the parsed one if the scan and the parser disagree on item count.
|
// back to the parsed one if the scan and the parser disagree on item count.
|
||||||
let enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
|
let mut enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
|
||||||
item.enclosure()
|
item.enclosure()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| Enclosure {
|
.map(|e| Enclosure {
|
||||||
@@ -463,6 +491,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.filter(|e| !e.url.is_empty())
|
.filter(|e| !e.url.is_empty())
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
drop_player_repeats(&mut enclosures);
|
||||||
|
|
||||||
let guid = pick_guid(
|
let guid = pick_guid(
|
||||||
item.guid().map(|g| g.value()),
|
item.guid().map(|g| g.value()),
|
||||||
@@ -479,7 +508,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
|
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
guid,
|
guid,
|
||||||
title: non_empty(item.title()),
|
title: title_text(item.title()),
|
||||||
link: non_empty(item.link()),
|
link: non_empty(item.link()),
|
||||||
published: item.pub_date().and_then(parse_date),
|
published: item.pub_date().and_then(parse_date),
|
||||||
// Content wins over description, as __getEntries preferred entry.content.
|
// Content wins over description, as __getEntries preferred entry.content.
|
||||||
@@ -501,7 +530,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
ParsedFeed {
|
ParsedFeed {
|
||||||
title: non_empty(Some(ch.title())),
|
title: title_text(Some(ch.title())),
|
||||||
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
|
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
|
||||||
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
|
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
|
||||||
image: ch
|
image: ch
|
||||||
@@ -509,6 +538,14 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.and_then(|i| i.image())
|
.and_then(|i| i.image())
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
||||||
|
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
|
||||||
|
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
|
||||||
|
// files every tabletop and gaming show under Leisure, which says little; Games says it.
|
||||||
|
category: ch
|
||||||
|
.itunes_ext()
|
||||||
|
.and_then(|i| i.categories().first())
|
||||||
|
.map(|c| c.subcategory().filter(|s| !s.text().trim().is_empty()).unwrap_or(c))
|
||||||
|
.and_then(|c| non_empty(Some(c.text().trim()))),
|
||||||
entries,
|
entries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,7 +556,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
// Atom carries enclosures as <link rel="enclosure">.
|
// Atom carries enclosures as <link rel="enclosure">.
|
||||||
let enclosures: Vec<Enclosure> = e
|
let mut enclosures: Vec<Enclosure> = e
|
||||||
.links()
|
.links()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| l.rel() == "enclosure")
|
.filter(|l| l.rel() == "enclosure")
|
||||||
@@ -530,6 +567,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
})
|
})
|
||||||
.filter(|e| !e.url.is_empty())
|
.filter(|e| !e.url.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
drop_player_repeats(&mut enclosures);
|
||||||
|
|
||||||
let alt = e
|
let alt = e
|
||||||
.links()
|
.links()
|
||||||
@@ -546,7 +584,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
|
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
guid,
|
guid,
|
||||||
title: non_empty(Some(e.title().as_str())),
|
title: title_text(Some(e.title().as_str())),
|
||||||
link: alt.map(str::to_owned),
|
link: alt.map(str::to_owned),
|
||||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
||||||
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
|
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
|
||||||
@@ -562,9 +600,10 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
ParsedFeed {
|
ParsedFeed {
|
||||||
title: non_empty(Some(feed.title().as_str())),
|
title: title_text(Some(feed.title().as_str())),
|
||||||
ttl_mins: None,
|
ttl_mins: None,
|
||||||
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
||||||
|
category: None,
|
||||||
entries,
|
entries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,6 +633,64 @@ fn non_empty(s: Option<&str>) -> Option<String> {
|
|||||||
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
|
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WordPress numbers each audio player on a page by adding `?_=N` to its file's URL, so a post
|
||||||
|
/// that embeds the file it encloses lists the same file twice: Rands in Repose's "The Promotion
|
||||||
|
/// Paradox" was downloaded twice and offered two play buttons for one mp3. The first stays.
|
||||||
|
fn drop_player_repeats(encs: &mut Vec<Enclosure>) {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
encs.retain(|e| seen.insert(same_file_key(&e.url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An enclosure URL without WordPress's player number, for telling repeats of one file apart
|
||||||
|
/// from different files.
|
||||||
|
pub fn same_file_key(url: &str) -> String {
|
||||||
|
let Ok(mut u) = url::Url::parse(url) else { return url.to_owned() };
|
||||||
|
let kept: Vec<(String, String)> = u
|
||||||
|
.query_pairs()
|
||||||
|
.filter(|(k, v)| !(k == "_" && !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())))
|
||||||
|
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||||
|
.collect();
|
||||||
|
if kept.is_empty() {
|
||||||
|
u.set_query(None);
|
||||||
|
} else {
|
||||||
|
u.query_pairs_mut().clear().extend_pairs(kept);
|
||||||
|
}
|
||||||
|
u.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A title as plain text. An Atom title of `type="html"`, or an RSS one in CDATA, comes through
|
||||||
|
/// the XML parser with its HTML entities intact: The Verge's "Meta’s" reached the page as
|
||||||
|
/// typed. Decoded one entity at a time, so an `&` that starts none, as in "Q&A", stays as it is
|
||||||
|
/// instead of failing the whole title.
|
||||||
|
fn title_text(s: Option<&str>) -> Option<String> {
|
||||||
|
let s = non_empty(s)?;
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut rest = s.as_str();
|
||||||
|
while let Some(at) = rest.find('&') {
|
||||||
|
out.push_str(&rest[..at]);
|
||||||
|
rest = &rest[at..];
|
||||||
|
let len = 1 + rest[1..]
|
||||||
|
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '#'))
|
||||||
|
.unwrap_or(rest.len() - 1);
|
||||||
|
let decoded = rest[len..]
|
||||||
|
.starts_with(';')
|
||||||
|
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
|
||||||
|
.flatten();
|
||||||
|
match decoded {
|
||||||
|
Some(v) => {
|
||||||
|
out.push_str(&v);
|
||||||
|
rest = &rest[len + 1..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push('&');
|
||||||
|
rest = &rest[1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
non_empty(Some(&out))
|
||||||
|
}
|
||||||
|
|
||||||
/// An item's show notes: its full body when that is whole, else its description.
|
/// An item's show notes: its full body when that is whole, else its description.
|
||||||
///
|
///
|
||||||
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
||||||
@@ -682,6 +779,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
|
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
|
||||||
assert_eq!(feed.ttl_mins, Some(45));
|
assert_eq!(feed.ttl_mins, Some(45));
|
||||||
|
assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory");
|
||||||
assert_eq!(feed.entries.len(), 3);
|
assert_eq!(feed.entries.len(), 3);
|
||||||
|
|
||||||
let ep = &feed.entries[0];
|
let ep = &feed.entries[0];
|
||||||
@@ -706,6 +804,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
||||||
|
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
||||||
|
<item><title>The Promotion Paradox</title><guid>p</guid>
|
||||||
|
<enclosure url="https://x/ep.mp3" length="1" type="audio/mpeg"/>
|
||||||
|
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
|
||||||
|
<enclosure url="https://x/other.mp3?_=3&key=k" length="1" type="audio/mpeg"/>
|
||||||
|
</item></channel></rss>"#;
|
||||||
|
let urls: Vec<String> =
|
||||||
|
parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
|
||||||
|
assert_eq!(urls, ["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"], "the repeat goes, a different file stays");
|
||||||
|
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
|
||||||
|
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn titles_are_read_as_text_not_html() {
|
||||||
|
// The Verge: an Atom title of type="html", its entity inside CDATA.
|
||||||
|
let xml = br#"<?xml version="1.0"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom"><title type="text">V</title><id>v</id>
|
||||||
|
<updated>2026-09-15T00:00:00Z</updated>
|
||||||
|
<entry><title type="html"><![CDATA[Meta’s new One]]></title><id>e1</id>
|
||||||
|
<updated>2026-09-15T00:00:00Z</updated></entry></feed>"#;
|
||||||
|
assert_eq!(parse(xml).unwrap().entries[0].title.as_deref(), Some("Meta\u{2019}s new One"));
|
||||||
|
// HTML names as well as numbers; a bare `&` and an unknown name are left as they are.
|
||||||
|
assert_eq!(
|
||||||
|
title_text(Some("Peña & “Q&A” &bogus; AT&T;")).as_deref(),
|
||||||
|
Some("Pe\u{f1}a & \u{201c}Q&A\u{201d} &bogus; AT&T;")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
|
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
|
||||||
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.
|
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.
|
||||||
@@ -748,7 +877,14 @@ mod tests {
|
|||||||
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
||||||
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
||||||
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
||||||
for transient in ["HTTP 500 Internal Server Error", "HTTP 429 Too Many Requests", "operation timed out"] {
|
let down = explain_failure("the site sent \"Unable to establish a DB connection\" instead of a feed").unwrap();
|
||||||
|
assert_eq!(down.reason, "The site sent a message instead of the feed; the publisher has to fix it.");
|
||||||
|
for transient in [
|
||||||
|
"HTTP 500 Internal Server Error",
|
||||||
|
"HTTP 429 Too Many Requests",
|
||||||
|
"operation timed out",
|
||||||
|
"not RSS (reached end of input without finding a complete channel) and not Atom (unexpected end of input)",
|
||||||
|
] {
|
||||||
assert!(explain_failure(transient).is_none(), "{transient} must not be flagged");
|
assert!(explain_failure(transient).is_none(), "{transient} must not be flagged");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,11 +905,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn garbage_that_is_not_html_gets_the_original_parser_errors() {
|
fn malformed_xml_gets_the_original_parser_errors() {
|
||||||
let err = parse(b"not xml at all").unwrap_err().to_string();
|
let err = parse(b"<rss><channel><title>cut off").unwrap_err().to_string();
|
||||||
assert!(err.starts_with("not RSS ("), "{err}");
|
assert!(err.starts_with("not RSS ("), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_body_with_no_markup_says_what_the_site_sent() {
|
||||||
|
let err = parse(b"\xef\xbb\xbf\r\n Unable to establish a DB connection\nmore").unwrap_err().to_string();
|
||||||
|
assert_eq!(err, "the site sent \"Unable to establish a DB connection\" instead of a feed");
|
||||||
|
let long = parse("x".repeat(200).as_bytes()).unwrap_err().to_string();
|
||||||
|
assert_eq!(long, format!("the site sent \"{}…\" instead of a feed", "x".repeat(80)));
|
||||||
|
assert_eq!(parse(b" \n").unwrap_err().to_string(), "the site sent an empty reply instead of a feed");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_atom_enclosure_links() {
|
fn parses_atom_enclosure_links() {
|
||||||
let bytes = include_bytes!("../tests/data/atom.xml");
|
let bytes = include_bytes!("../tests/data/atom.xml");
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
|||||||
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
|
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
|
||||||
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
|
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
|
||||||
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
||||||
pub type StatusFn = std::sync::Arc<dyn Fn() -> Event + Send + Sync>;
|
/// A future, since reading the counts is a database query.
|
||||||
|
pub type StatusFn =
|
||||||
|
std::sync::Arc<dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
|
||||||
|
|
||||||
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
@@ -249,7 +251,7 @@ async fn handle(
|
|||||||
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
||||||
Ok(Command::Status) => {
|
Ok(Command::Status) => {
|
||||||
tracing::info!(target: "ipx::io", "-> {line}");
|
tracing::info!(target: "ipx::io", "-> {line}");
|
||||||
let ev = status();
|
let ev = status().await;
|
||||||
if let Ok(json) = serde_json::to_string(&ev) {
|
if let Ok(json) = serde_json::to_string(&ev) {
|
||||||
tracing::info!(target: "ipx::io", "<- {json}");
|
tracing::info!(target: "ipx::io", "<- {json}");
|
||||||
}
|
}
|
||||||
@@ -366,7 +368,8 @@ mod tests {
|
|||||||
// Another client, watching a scan: it must not be handed someone else's answer, which
|
// Another client, watching a scan: it must not be handed someone else's answer, which
|
||||||
// would end its session.
|
// would end its session.
|
||||||
let mut watcher = events.subscribe();
|
let mut watcher = events.subscribe();
|
||||||
let status: StatusFn = std::sync::Arc::new(|| Event::Status { feeds: 1, pending: 2, downloaded: 3 });
|
let status: StatusFn =
|
||||||
|
std::sync::Arc::new(|| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
|
||||||
let (client, server) = UnixStream::pair().unwrap();
|
let (client, server) = UnixStream::pair().unwrap();
|
||||||
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
||||||
|
|
||||||
|
|||||||
462
src/main.rs
462
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -45,28 +45,28 @@ pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||||
let mut report = Report::default();
|
let mut report = Report::default();
|
||||||
|
|
||||||
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
||||||
for (id, path) in db.missing_files()? {
|
for (id, path) in db.missing_files().await? {
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
db.mark_reaped(id)?;
|
db.mark_reaped(id).await?;
|
||||||
}
|
}
|
||||||
tracing::debug!(path, "file gone, row reaped");
|
tracing::debug!(path, "file gone, row reaped");
|
||||||
report.reconciled += 1;
|
report.reconciled += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = db.reap_candidates()?;
|
let candidates = db.reap_candidates().await?;
|
||||||
|
|
||||||
if cfg.general.max_age_days > 0 {
|
if cfg.general.max_age_days > 0 {
|
||||||
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||||
report.aged_out = aged(&candidates, cutoff);
|
report.aged_out = aged(&candidates, cutoff);
|
||||||
for c in &report.aged_out {
|
for c in &report.aged_out {
|
||||||
report.bytes_freed += remove(db, c, dry_run)?;
|
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||||
}
|
}
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
report.entries_pruned = db.prune_entries(cutoff)?;
|
report.entries_pruned = db.prune_entries(cutoff).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,14 +81,14 @@ pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
||||||
report.over_quota = pick(&remaining, total, limit);
|
report.over_quota = pick(&remaining, total, limit);
|
||||||
for c in &report.over_quota {
|
for c in &report.over_quota {
|
||||||
report.bytes_freed += remove(db, c, dry_run)?;
|
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||||
if dry_run {
|
if dry_run {
|
||||||
return Ok(c.bytes.max(0) as u64);
|
return Ok(c.bytes.max(0) as u64);
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
|||||||
tracing::warn!(path = c.path, error = %e, "could not delete");
|
tracing::warn!(path = c.path, error = %e, "could not delete");
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
db.mark_reaped(c.id)?;
|
db.mark_reaped(c.id).await?;
|
||||||
Ok(size)
|
Ok(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +149,12 @@ mod tests {
|
|||||||
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
async fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
||||||
// One file serves both subscribers, so it takes both of them to release it.
|
// One file serves both subscribers, so it takes both of them to release it.
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
|
||||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'keep', 0),
|
('f', 'keep', 0),
|
||||||
@@ -163,20 +163,20 @@ mod tests {
|
|||||||
('f', 'read', 0);
|
('f', 'read', 0);
|
||||||
-- Starred by one of the two, so it stays whatever the other thinks.
|
-- Starred by one of the two, so it stays whatever the other thinks.
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||||
(1, 'f', 'keep', 1, 1),
|
(1, 'f', 'keep', true, true),
|
||||||
(2, 'f', 'keep', 1, 0),
|
(2, 'f', 'keep', true, false),
|
||||||
(1, 'f', 'half', 1, 0),
|
(1, 'f', 'half', true, false),
|
||||||
(1, 'f', 'read', 1, 0),
|
(1, 'f', 'read', true, false),
|
||||||
(2, 'f', 'read', 1, 0);
|
(2, 'f', 'read', true, false);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||||
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
||||||
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||||
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||||
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||||
)
|
).await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect();
|
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
got,
|
got,
|
||||||
vec![4, 2, 3],
|
vec![4, 2, 3],
|
||||||
@@ -185,12 +185,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn prune_keeps_entries_that_still_have_a_file() {
|
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
|
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'has-file', 100),
|
('f', 'has-file', 100),
|
||||||
('f', 'no-file', 100),
|
('f', 'no-file', 100),
|
||||||
@@ -198,9 +198,9 @@ mod tests {
|
|||||||
('f', 'recent', 900);
|
('f', 'recent', 900);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
||||||
)
|
).await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(db.prune_entries(500).unwrap(), 1, "only the old, fileless, unflagged one");
|
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
474
src/web.rs
474
src/web.rs
@@ -16,7 +16,6 @@ use serde::Deserialize;
|
|||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use tower_http::services::ServeFile;
|
use tower_http::services::ServeFile;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ const COOKIE: &str = "ipx_token";
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebState {
|
pub struct WebState {
|
||||||
pub ctx: Arc<Ctx>,
|
pub ctx: Arc<Ctx>,
|
||||||
pub config_path: PathBuf,
|
|
||||||
pub cmds: mpsc::Sender<Command>,
|
pub cmds: mpsc::Sender<Command>,
|
||||||
pub events: broadcast::Sender<Event>,
|
pub events: broadcast::Sender<Event>,
|
||||||
}
|
}
|
||||||
@@ -36,7 +34,7 @@ pub struct WebState {
|
|||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/api/me", get(me))
|
.route("/api/me", get(me).patch(patch_me))
|
||||||
.route("/api/logout", post(logout))
|
.route("/api/logout", post(logout))
|
||||||
.route("/api/feeds", get(feeds).post(add_feed))
|
.route("/api/feeds", get(feeds).post(add_feed))
|
||||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||||
@@ -59,12 +57,21 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
||||||
.route("/api/logs", get(logs))
|
.route("/api/logs", get(logs))
|
||||||
.route("/api/events", get(events))
|
.route("/api/events", get(events))
|
||||||
|
.route("/admin", get(admin_page))
|
||||||
|
.route("/admin.js", get(admin_js))
|
||||||
.route("/media/{id}", get(media))
|
.route("/media/{id}", get(media))
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||||
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
||||||
.route("/login", get(login_page))
|
.route("/login", get(login_page))
|
||||||
.route("/api/login", post(login))
|
.route("/api/login", post(login))
|
||||||
.route("/icon.png", get(icon))
|
.route("/icon.png", get(icon))
|
||||||
|
.route("/favicon.ico", get(favicon))
|
||||||
|
.route("/favicon.png", get(favicon))
|
||||||
|
.route("/apple-touch-icon.png", get(touch_icon))
|
||||||
|
.route("/app.js", get(app_js))
|
||||||
|
.route("/app.css", get(app_css))
|
||||||
|
.route("/login.js", get(login_js))
|
||||||
|
.route("/inter.woff2", get(inter))
|
||||||
.layer(middleware::from_fn(access_log))
|
.layer(middleware::from_fn(access_log))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
@@ -98,16 +105,16 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let mut user = None;
|
let mut user = None;
|
||||||
|
|
||||||
if let Some(name) = vouched {
|
if let Some(name) = vouched {
|
||||||
user = match state.ctx.db.user_by_name(&name) {
|
user = match state.ctx.db.user_by_name(&name).await {
|
||||||
Ok(Some(u)) => Some(u),
|
Ok(Some(u)) => Some(u),
|
||||||
Ok(None) if cfg.web.auto_create_users => {
|
Ok(None) if cfg.web.auto_create_users => {
|
||||||
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
|
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
|
||||||
state
|
// The first account made is the admin.
|
||||||
.ctx
|
let first = state.ctx.db.users().await.map(|u| u.is_empty()).unwrap_or(false);
|
||||||
.db
|
match state.ctx.db.create_user(&name, None, first).await {
|
||||||
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
|
Ok(id) => state.ctx.db.user_by_id(id).await.ok().flatten(),
|
||||||
.ok()
|
Err(_) => None,
|
||||||
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
|
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
|
||||||
@@ -121,7 +128,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
|
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
|
||||||
// must not turn anyone away, so its error goes unanswered.
|
// must not turn anyone away, so its error goes unanswered.
|
||||||
if let Some(u) = &user {
|
if let Some(u) = &user {
|
||||||
let _ = state.ctx.db.signed_in(u.id);
|
let _ = state.ctx.db.signed_in(u.id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let by_proxy = user.is_some();
|
let by_proxy = user.is_some();
|
||||||
@@ -132,7 +139,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
user = state
|
user = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.session_user(&sid, cfg.web.session_days.max(1) * 86_400)
|
.session_user(&sid, cfg.web.session_days.max(1) * 86_400).await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,11 +153,11 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
if user.is_none() && !token.is_empty() {
|
if user.is_none() && !token.is_empty() {
|
||||||
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
|
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
|
||||||
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
|
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
|
||||||
user = admin_user(&state);
|
user = admin_user(&state).await;
|
||||||
if from_query.is_some() {
|
if from_query.is_some() {
|
||||||
// The token link is a sign-in; the cookie it leaves behind is not one each time.
|
// The token link is a sign-in; the cookie it leaves behind is not one each time.
|
||||||
if let Some(u) = &user {
|
if let Some(u) = &user {
|
||||||
let _ = state.ctx.db.signed_in(u.id);
|
let _ = state.ctx.db.signed_in(u.id).await;
|
||||||
}
|
}
|
||||||
set_cookie = Some(format!(
|
set_cookie = Some(format!(
|
||||||
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
||||||
@@ -238,8 +245,8 @@ fn cookie(req: &Request, name: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The account the shared token stands for: the first admin, or the first user at all.
|
/// The account the shared token stands for: the first admin, or the first user at all.
|
||||||
fn admin_user(state: &WebState) -> Option<crate::db::User> {
|
async fn admin_user(state: &WebState) -> Option<crate::db::User> {
|
||||||
let users = state.ctx.db.users().ok()?;
|
let users = state.ctx.db.users().await.ok()?;
|
||||||
users
|
users
|
||||||
.iter()
|
.iter()
|
||||||
.find(|u| u.is_admin)
|
.find(|u| u.is_admin)
|
||||||
@@ -258,7 +265,7 @@ async fn login(
|
|||||||
Json(body): Json<Credentials>,
|
Json(body): Json<Credentials>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let name = body.name.trim().to_ascii_lowercase();
|
let name = body.name.trim().to_ascii_lowercase();
|
||||||
let user = state.ctx.db.user_by_name(&name)?;
|
let user = state.ctx.db.user_by_name(&name).await?;
|
||||||
// The same answer either way: whether a name exists is not something to leak.
|
// The same answer either way: whether a name exists is not something to leak.
|
||||||
let ok = user
|
let ok = user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -271,8 +278,8 @@ async fn login(
|
|||||||
|
|
||||||
let user = user.expect("verified above");
|
let user = user.expect("verified above");
|
||||||
let token = crate::auth::new_session_token();
|
let token = crate::auth::new_session_token();
|
||||||
state.ctx.db.create_session(user.id, &token)?;
|
state.ctx.db.create_session(user.id, &token).await?;
|
||||||
state.ctx.db.signed_in(user.id)?;
|
state.ctx.db.signed_in(user.id).await?;
|
||||||
tracing::info!(user = %user.name, "signed in");
|
tracing::info!(user = %user.name, "signed in");
|
||||||
|
|
||||||
let days = state.ctx.cfg().web.session_days.max(1);
|
let days = state.ctx.cfg().web.session_days.max(1);
|
||||||
@@ -289,7 +296,7 @@ async fn login(
|
|||||||
|
|
||||||
async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
||||||
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
||||||
let _ = state.ctx.db.delete_session(&sid);
|
let _ = state.ctx.db.delete_session(&sid).await;
|
||||||
}
|
}
|
||||||
let mut resp = StatusCode::NO_CONTENT.into_response();
|
let mut resp = StatusCode::NO_CONTENT.into_response();
|
||||||
for c in [
|
for c in [
|
||||||
@@ -311,7 +318,37 @@ async fn me(
|
|||||||
) -> Json<serde_json::Value> {
|
) -> Json<serde_json::Value> {
|
||||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out }))
|
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MePatch {
|
||||||
|
theme: String,
|
||||||
|
mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the theme to the account, so it follows the person rather than the browser.
|
||||||
|
async fn patch_me(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
Json(body): Json<MePatch>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// The page's script knows the themes; this only makes sure what is kept is safe to write
|
||||||
|
// into the page's <html> tag, which is where index() puts it.
|
||||||
|
if !theme_ok(&body.theme, &body.mode) {
|
||||||
|
return Err(ApiError::bad_request("not a theme"));
|
||||||
|
}
|
||||||
|
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_ok(theme: &str, mode: &str) -> bool {
|
||||||
|
(1..=20).contains(&theme.len())
|
||||||
|
&& theme.bytes().all(|b| b.is_ascii_lowercase())
|
||||||
|
&& ["light", "dark", "auto"].contains(&mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- accounts: admin only ----
|
// ---- accounts: admin only ----
|
||||||
@@ -339,7 +376,7 @@ async fn list_users(
|
|||||||
let users: Vec<_> = state
|
let users: Vec<_> = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.users()?
|
.users().await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| {
|
.map(|u| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -370,7 +407,7 @@ async fn add_user(
|
|||||||
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
|
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
|
||||||
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
|
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
|
||||||
})?;
|
})?;
|
||||||
if state.ctx.db.user_by_name(&name)?.is_some() {
|
if state.ctx.db.user_by_name(&name).await?.is_some() {
|
||||||
return Err(ApiError::bad_request(format!("{name} already exists")));
|
return Err(ApiError::bad_request(format!("{name} already exists")));
|
||||||
}
|
}
|
||||||
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
|
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
|
||||||
@@ -379,7 +416,7 @@ async fn add_user(
|
|||||||
} else {
|
} else {
|
||||||
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
|
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
|
||||||
};
|
};
|
||||||
state.ctx.db.create_user(&name, hash.as_deref(), body.admin)?;
|
state.ctx.db.create_user(&name, hash.as_deref(), body.admin).await?;
|
||||||
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
|
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
|
||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
@@ -396,7 +433,7 @@ async fn patch_user(
|
|||||||
Json(body): Json<UserPatch>,
|
Json(body): Json<UserPatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
let users = state.ctx.db.users()?;
|
let users = state.ctx.db.users().await?;
|
||||||
let target = users
|
let target = users
|
||||||
.iter()
|
.iter()
|
||||||
.find(|u| u.id == id)
|
.find(|u| u.id == id)
|
||||||
@@ -407,7 +444,7 @@ async fn patch_user(
|
|||||||
target.name
|
target.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
state.ctx.db.set_admin(id, body.admin)?;
|
state.ctx.db.set_admin(id, body.admin).await?;
|
||||||
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
|
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -418,7 +455,7 @@ async fn remove_user(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
let users = state.ctx.db.users()?;
|
let users = state.ctx.db.users().await?;
|
||||||
let target = users
|
let target = users
|
||||||
.iter()
|
.iter()
|
||||||
.find(|u| u.id == id)
|
.find(|u| u.id == id)
|
||||||
@@ -429,7 +466,7 @@ async fn remove_user(
|
|||||||
target.name
|
target.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
state.ctx.db.delete_user(id)?;
|
state.ctx.db.delete_user(id).await?;
|
||||||
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -440,7 +477,55 @@ async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
|||||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
||||||
return Redirect::to("/").into_response();
|
return Redirect::to("/").into_response();
|
||||||
}
|
}
|
||||||
Html(include_str!("../web/login.html")).into_response()
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pages are checked on every visit, so a browser always has the one naming the current
|
||||||
|
/// scripts; the scripts, named by a hash of their contents (/app.js?v=<hash>, see
|
||||||
|
/// web/build.mjs), are kept a year and never asked for again. A deploy that changes a script
|
||||||
|
/// changes its name in the page, and the browser fetches it.
|
||||||
|
const PAGE_CACHE: &str = "no-cache";
|
||||||
|
const SCRIPT_CACHE: &str = "public, max-age=31536000, immutable";
|
||||||
|
|
||||||
|
/// The page's script, and the sign-in page's. Outside the auth layer, like the icon: the sign-in
|
||||||
|
/// page needs its own before anyone has signed in, and neither holds anything private.
|
||||||
|
async fn app_js() -> impl IntoResponse {
|
||||||
|
script(include_str!(concat!(env!("OUT_DIR"), "/app.js")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stylesheet the app and admin pages share, named by hash in each as the scripts are.
|
||||||
|
async fn app_css() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)],
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/app.css")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The admin page and its script go to admins only: not just hidden from everyone else, never
|
||||||
|
/// sent. Anyone else asking for the page is sent back to the app.
|
||||||
|
async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Response {
|
||||||
|
if !user.is_admin {
|
||||||
|
return Redirect::to("/").into_response();
|
||||||
|
}
|
||||||
|
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
|
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
||||||
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_js(user: crate::db::User) -> Response {
|
||||||
|
if !user.is_admin {
|
||||||
|
return (StatusCode::FORBIDDEN, "only an admin").into_response();
|
||||||
|
}
|
||||||
|
script(include_str!(concat!(env!("OUT_DIR"), "/admin.js"))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_js() -> impl IntoResponse {
|
||||||
|
script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(js: &'static str) -> impl IntoResponse {
|
||||||
|
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
||||||
@@ -452,6 +537,35 @@ async fn icon() -> impl IntoResponse {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
|
||||||
|
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
|
||||||
|
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
|
||||||
|
async fn favicon() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
|
include_bytes!("../web/favicon.png").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
|
||||||
|
/// one is on white.
|
||||||
|
async fn touch_icon() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
|
include_bytes!("../web/apple-touch-icon.png").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
|
||||||
|
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
|
||||||
|
/// Open Font License, is web/Inter-LICENSE.txt.
|
||||||
|
async fn inter() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "font/woff2"), (header::CACHE_CONTROL, "max-age=604800")],
|
||||||
|
include_bytes!("../web/InterVariable.woff2").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
@@ -460,8 +574,45 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
|
|||||||
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index() -> Html<&'static str> {
|
// Built by build.rs from web/index.html and web/src, and minified: attribute values lose their
|
||||||
Html(include_str!("../web/index.html"))
|
// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
|
||||||
|
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
|
||||||
|
const ADMIN_LINK: &str = "<a id=admin ";
|
||||||
|
|
||||||
|
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
||||||
|
/// script instead showed it for a moment on every load, until /api/me answered.
|
||||||
|
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
|
||||||
|
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTML_TAG: &str = "<html lang=en>";
|
||||||
|
|
||||||
|
/// The page for this person: their theme on its <html> tag, and the link to /admin only if they
|
||||||
|
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
|
||||||
|
/// showed it for a moment on every load, until /api/me answered (issue #29).
|
||||||
|
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
|
||||||
|
let mut page = with_theme(INDEX, theme);
|
||||||
|
if !admin
|
||||||
|
&& let Some(at) = page.find(ADMIN_LINK)
|
||||||
|
&& let Some(len) = page[at..].find("</a>")
|
||||||
|
{
|
||||||
|
page.replace_range(at..at + len + "</a>".len(), "");
|
||||||
|
}
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page with the account's theme on its <html> tag, so it is drawn in it from the first frame
|
||||||
|
/// on any browser.
|
||||||
|
fn with_theme(page: &str, theme: (Option<String>, Option<String>)) -> String {
|
||||||
|
let (Some(t), Some(m)) = theme else { return page.to_owned() };
|
||||||
|
if !theme_ok(&t, &m) {
|
||||||
|
return page.to_owned();
|
||||||
|
}
|
||||||
|
// data-choice is light, dark or auto; data-mode, what the CSS reads, is only known here for
|
||||||
|
// the first two. theme.ts works Auto out from the system.
|
||||||
|
let mode = if m == "auto" { String::new() } else { format!(" data-mode={m}") };
|
||||||
|
page.replacen(HTML_TAG, &format!("<html lang=en data-theme={t} data-choice={m}{mode}>"), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -471,6 +622,9 @@ struct FeedRow {
|
|||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
|
/// The Directory category an admin gave it, and the one the feed names itself, which wins.
|
||||||
|
category: Option<String>,
|
||||||
|
feed_category: Option<String>,
|
||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
allow_explicit: bool,
|
allow_explicit: bool,
|
||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
@@ -496,6 +650,8 @@ struct FeedRow {
|
|||||||
unread: i64,
|
unread: i64,
|
||||||
/// Including you. More than one means every file here is shared.
|
/// Including you. More than one means every file here is shared.
|
||||||
subscribers: i64,
|
subscribers: i64,
|
||||||
|
/// Pinned to the top of your list.
|
||||||
|
pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -514,15 +670,16 @@ async fn feeds(
|
|||||||
let cfg = state.ctx.cfg();
|
let cfg = state.ctx.cfg();
|
||||||
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
||||||
// What comes back is only the part of it this person subscribes to.
|
// What comes back is only the part of it this person subscribes to.
|
||||||
let subs = crate::subscriptions(&state.ctx)?;
|
let subs = crate::subscriptions(&state.ctx).await?;
|
||||||
let mine: std::collections::HashMap<String, crate::db::Sub> = state
|
let mine: std::collections::HashMap<String, crate::db::Sub> = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.subscriptions_for(user.id)?
|
.subscriptions_for(user.id).await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|s| (s.feed_id.clone(), s))
|
.map(|s| (s.feed_id.clone(), s))
|
||||||
.collect();
|
.collect();
|
||||||
let counts = state.ctx.db.subscriber_counts()?;
|
let counts = state.ctx.db.subscriber_counts().await?;
|
||||||
|
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
|
||||||
let mut out = Vec::with_capacity(mine.len());
|
let mut out = Vec::with_capacity(mine.len());
|
||||||
for sub in &subs {
|
for sub in &subs {
|
||||||
let (id, feed) = (&sub.id, &sub.cfg);
|
let (id, feed) = (&sub.id, &sub.cfg);
|
||||||
@@ -530,14 +687,16 @@ async fn feeds(
|
|||||||
// the same fallback the scanner uses (`Db::subscribers`).
|
// the same fallback the scanner uses (`Db::subscribers`).
|
||||||
let up = feed.group.as_deref().and_then(|g| mine.get(g));
|
let up = feed.group.as_deref().and_then(|g| mine.get(g));
|
||||||
let Some(mine) = mine.get(id) else { continue };
|
let Some(mine) = mine.get(id) else { continue };
|
||||||
let s = state.ctx.db.feed_summary(id)?;
|
let s = state.ctx.db.feed_summary(id).await?;
|
||||||
let st = state.ctx.db.http_state(id)?;
|
let st = state.ctx.db.http_state(id).await?;
|
||||||
out.push(FeedRow {
|
out.push(FeedRow {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
url: feed.url.clone(),
|
url: feed.url.clone(),
|
||||||
title: s.title,
|
title: s.title,
|
||||||
image: s.image,
|
image: s.image,
|
||||||
folder: feed.folder.clone(),
|
folder: feed.folder.clone(),
|
||||||
|
category: feed.category.clone(),
|
||||||
|
feed_category: s.category,
|
||||||
keywords: mine
|
keywords: mine
|
||||||
.keywords
|
.keywords
|
||||||
.clone()
|
.clone()
|
||||||
@@ -579,8 +738,9 @@ async fn feeds(
|
|||||||
last_error: s.last_error,
|
last_error: s.last_error,
|
||||||
entries: s.entries,
|
entries: s.entries,
|
||||||
downloaded: s.downloaded,
|
downloaded: s.downloaded,
|
||||||
unread: state.ctx.db.unread_count(user.id, id)?,
|
unread: state.ctx.db.unread_count(user.id, id).await?,
|
||||||
subscribers: counts.get(id).copied().unwrap_or(0),
|
subscribers: counts.get(id).copied().unwrap_or(0),
|
||||||
|
pinned: pinned.contains(id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(Json(out))
|
Ok(Json(out))
|
||||||
@@ -627,18 +787,24 @@ struct PopularRow {
|
|||||||
subscribers: i64,
|
subscribers: i64,
|
||||||
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
|
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
|
||||||
subscribed: bool,
|
subscribed: bool,
|
||||||
|
/// The feed's own iTunes category, if it names one; most blogs do not.
|
||||||
|
category: Option<String>,
|
||||||
|
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
|
||||||
|
/// Directory's Podcasts and Blogs between them hold everything.
|
||||||
|
podcast: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every feed that may be listed, with everyone counted, you included, most subscribers
|
/// Every feed that may be listed, with everyone counted, you included, most subscribers
|
||||||
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
||||||
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
||||||
/// feeds inside it and never itself: both lists are for finding a show.
|
/// feeds inside it and never itself: both lists are for finding a show.
|
||||||
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
let mine: std::collections::HashSet<String> =
|
let mine: std::collections::HashSet<String> =
|
||||||
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let counts = db.subscriber_counts()?;
|
let counts = db.subscriber_counts().await?;
|
||||||
let catalogue = crate::subscriptions(&state.ctx)?;
|
let media = db.media_feeds().await?;
|
||||||
|
let catalogue = crate::subscriptions(&state.ctx).await?;
|
||||||
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
||||||
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||||
let is_folder: std::collections::HashSet<&str> =
|
let is_folder: std::collections::HashSet<&str> =
|
||||||
@@ -655,9 +821,18 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sum = db.feed_summary(&s.id)?;
|
let sum = db.feed_summary(&s.id).await?;
|
||||||
let subscribed = mine.contains(&s.id);
|
let subscribed = mine.contains(&s.id);
|
||||||
out.push(PopularRow { id: s.id.clone(), title: sum.title, image: sum.image, subscribers: n, subscribed });
|
out.push(PopularRow {
|
||||||
|
id: s.id.clone(),
|
||||||
|
title: sum.title,
|
||||||
|
image: sum.image,
|
||||||
|
subscribers: n,
|
||||||
|
subscribed,
|
||||||
|
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
|
||||||
|
category: sum.category.or_else(|| s.cfg.category.clone()),
|
||||||
|
podcast: media.contains(&s.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
|
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -667,7 +842,7 @@ async fn get_popular(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
let mut rows = popular(&state, user.id)?;
|
let mut rows = popular(&state, user.id).await?;
|
||||||
rows.truncate(10);
|
rows.truncate(10);
|
||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
@@ -677,7 +852,7 @@ async fn get_directory(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
let mut rows = popular(&state, user.id)?;
|
let mut rows = popular(&state, user.id).await?;
|
||||||
rows.sort_by_key(sort_name);
|
rows.sort_by_key(sort_name);
|
||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
@@ -693,10 +868,10 @@ async fn subscribe_popular(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
|
if !popular(&state, user.id).await?.iter().any(|p| p.id == id) {
|
||||||
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
||||||
}
|
}
|
||||||
state.ctx.db.subscribe(user.id, &id)?;
|
state.ctx.db.subscribe(user.id, &id).await?;
|
||||||
Ok(Json(serde_json::json!({ "id": id })))
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,6 +924,13 @@ impl ApiError {
|
|||||||
status: StatusCode::FORBIDDEN,
|
status: StatusCode::FORBIDDEN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn not_found(msg: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
error: anyhow::Error::msg(msg.into()),
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
@@ -764,6 +946,38 @@ impl IntoResponse for ApiError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_relative_image_resolves_against_the_post() {
|
||||||
|
let mut b = feed_sanitizer();
|
||||||
|
let html = r#"<img src="images/a.webp"><a href="/about">x</a><script>bad()</script>"#;
|
||||||
|
let out = clean_description(&mut b, html, Some("https://example.com/2026/04/post/"));
|
||||||
|
assert!(out.contains(r#"src="https://example.com/2026/04/post/images/a.webp""#), "{out}");
|
||||||
|
assert!(out.contains(r#"href="https://example.com/about""#), "{out}");
|
||||||
|
assert!(!out.contains("bad()"), "{out}");
|
||||||
|
assert!(out.contains(r#"referrerpolicy="no-referrer""#), "{out}");
|
||||||
|
assert!(clean_description(&mut b, html, None).contains(r#"src="images/a.webp""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_an_admin_is_sent_the_admin_link() {
|
||||||
|
// If the markup drifts from ADMIN_LINK, find matches nothing and says nothing.
|
||||||
|
assert!(page_for(true, (None, None)).contains(ADMIN_LINK));
|
||||||
|
let page = page_for(false, (None, None));
|
||||||
|
assert!(!page.contains(ADMIN_LINK) && !page.contains("href=/admin"), "the link is gone");
|
||||||
|
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_page_arrives_in_the_theme_the_account_chose() {
|
||||||
|
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
|
||||||
|
// If the minifier ever writes the tag differently, HTML_TAG matches nothing, silently.
|
||||||
|
assert!(page("dracula", "dark").contains("<html lang=en data-theme=dracula data-choice=dark data-mode=dark>"));
|
||||||
|
assert!(page("nordic", "auto").contains("<html lang=en data-theme=nordic data-choice=auto>"));
|
||||||
|
// Whatever is in the column is written into markup, so only a plain name gets there.
|
||||||
|
assert!(!page("x onload=alert(1)", "dark").contains("onload"));
|
||||||
|
assert!(page("modern", "dark\"").contains("<html lang=en>"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_feed_with_a_credential_is_never_popular() {
|
fn a_feed_with_a_credential_is_never_popular() {
|
||||||
let f = |url: &str| crate::config::Feed {
|
let f = |url: &str| crate::config::Feed {
|
||||||
@@ -779,6 +993,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
};
|
};
|
||||||
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
||||||
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
||||||
@@ -825,7 +1040,7 @@ mod tests {
|
|||||||
crate::config::Feed {
|
crate::config::Feed {
|
||||||
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||||
auto_download: true, max_new_per_check: None, username: None,
|
auto_download: true, max_new_per_check: None, username: None,
|
||||||
password: None, password_env: None,
|
password: None, password_env: None, category: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,9 +1052,10 @@ mod tests {
|
|||||||
assert!(absent.schedule.is_none() && absent.folder.is_none());
|
assert!(absent.schedule.is_none() && absent.folder.is_none());
|
||||||
|
|
||||||
let cleared: FeedPatch =
|
let cleared: FeedPatch =
|
||||||
serde_json::from_str(r#"{"schedule":null,"folder":null,"max_new_per_check":null}"#).unwrap();
|
serde_json::from_str(r#"{"schedule":null,"folder":null,"category":null,"max_new_per_check":null}"#).unwrap();
|
||||||
assert_eq!(cleared.schedule, Some(None), "null must mean clear");
|
assert_eq!(cleared.schedule, Some(None), "null must mean clear");
|
||||||
assert_eq!(cleared.folder, Some(None));
|
assert_eq!(cleared.folder, Some(None));
|
||||||
|
assert_eq!(cleared.category, Some(None));
|
||||||
assert_eq!(cleared.max_new_per_check, Some(None));
|
assert_eq!(cleared.max_new_per_check, Some(None));
|
||||||
|
|
||||||
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
|
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
|
||||||
@@ -906,7 +1122,7 @@ async fn entries(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Query(page): Query<Page>,
|
Query(page): Query<Page>,
|
||||||
) -> Result<Json<EntryPage>, ApiError> {
|
) -> Result<Json<EntryPage>, ApiError> {
|
||||||
entry_page(&state, user.id, Some(&id), &page)
|
entry_page(&state, user.id, Some(&id), &page).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every subscribed feed's items together, newest first: All Subscriptions.
|
/// Every subscribed feed's items together, newest first: All Subscriptions.
|
||||||
@@ -915,11 +1131,11 @@ async fn all_entries(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Query(page): Query<Page>,
|
Query(page): Query<Page>,
|
||||||
) -> Result<Json<EntryPage>, ApiError> {
|
) -> Result<Json<EntryPage>, ApiError> {
|
||||||
entry_page(&state, user.id, None, &page)
|
entry_page(&state, user.id, None, &page).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
||||||
fn entry_page(
|
async fn entry_page(
|
||||||
state: &WebState,
|
state: &WebState,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
feed: Option<&str>,
|
feed: Option<&str>,
|
||||||
@@ -928,26 +1144,43 @@ fn entry_page(
|
|||||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
||||||
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
let order = crate::db::order_sql(
|
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
|
||||||
page.sort.as_deref().unwrap_or("published"),
|
|
||||||
page.dir.as_deref().unwrap_or("desc"),
|
|
||||||
);
|
|
||||||
let mut rows =
|
let mut rows =
|
||||||
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
|
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
|
||||||
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
let mut sanitizer = feed_sanitizer();
|
||||||
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
|
|
||||||
// keeps that safe -- so following one in show notes never navigates away from ipx.
|
|
||||||
let mut sanitizer = ammonia::Builder::new();
|
|
||||||
sanitizer.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
|
|
||||||
for row in &mut rows {
|
for row in &mut rows {
|
||||||
if let Some(d) = &row.description {
|
if let Some(d) = &row.description {
|
||||||
row.description = Some(sanitizer.clean(d).to_string());
|
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let total = db.count_in(user_id, feed, filter, search)?;
|
let total = db.count_in(user_id, feed, filter, search).await?;
|
||||||
Ok(Json(EntryPage { total, entries: rows }))
|
Ok(Json(EntryPage { total, entries: rows }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
||||||
|
fn feed_sanitizer() -> ammonia::Builder<'static> {
|
||||||
|
let mut b = ammonia::Builder::new();
|
||||||
|
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
|
||||||
|
// keeps that safe -- so following one in show notes never navigates away from ipx.
|
||||||
|
b.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
|
||||||
|
// An image is asked for without saying it is shown on ipx. A site that refuses images to
|
||||||
|
// other sites' pages goes by that: jeffgeerling.com answers 403, and his posts showed a
|
||||||
|
// broken image on iOS and only the alt text on desktop.
|
||||||
|
b.add_tag_attributes("img", &["referrerpolicy"]).set_tag_attribute_value("img", "referrerpolicy", "no-referrer");
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A relative `src` or `href` in a post means relative to the post, not to ipx: The
|
||||||
|
/// Observation Deck's `images/37k-a-day-bro.webp` came up as a broken image.
|
||||||
|
fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<&str>) -> String {
|
||||||
|
let base = link.and_then(|l| url::Url::parse(l).ok());
|
||||||
|
sanitizer.url_relative(match base {
|
||||||
|
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
|
||||||
|
None => ammonia::UrlRelative::PassThrough,
|
||||||
|
});
|
||||||
|
sanitizer.clean(html).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct NewFeed {
|
struct NewFeed {
|
||||||
url: String,
|
url: String,
|
||||||
@@ -962,10 +1195,10 @@ struct NewFeed {
|
|||||||
/// The Add feed dialog's explicit box. Like everything on a feed's own dialog it is yours, so it
|
/// The Add feed dialog's explicit box. Like everything on a feed's own dialog it is yours, so it
|
||||||
/// goes on your subscription, and before the first scan, which would otherwise skip every
|
/// goes on your subscription, and before the first scan, which would otherwise skip every
|
||||||
/// explicit item.
|
/// explicit item.
|
||||||
fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
|
async fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
|
||||||
if allow {
|
if allow {
|
||||||
let sub = crate::db::Sub { feed_id: feed_id.to_owned(), allow_explicit: Some(true), ..Default::default() };
|
let sub = crate::db::Sub { feed_id: feed_id.to_owned(), allow_explicit: Some(true), ..Default::default() };
|
||||||
state.ctx.db.set_subscription(user_id, &sub)?;
|
state.ctx.db.set_subscription(user_id, &sub).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -979,14 +1212,14 @@ async fn add_feed(
|
|||||||
let url = crate::feed::expand_input(&body.url);
|
let url = crate::feed::expand_input(&body.url);
|
||||||
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
||||||
// second copy on disk, just another name against the same feed.
|
// second copy on disk, just another name against the same feed.
|
||||||
if let Some(existing) = crate::subscriptions(&state.ctx)?
|
if let Some(existing) = crate::subscriptions(&state.ctx).await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
|
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
|
||||||
{
|
{
|
||||||
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
|
let already = state.ctx.db.subscription(user.id, &existing.id).await?.is_some();
|
||||||
state.ctx.db.subscribe(user.id, &existing.id)?;
|
state.ctx.db.subscribe(user.id, &existing.id).await?;
|
||||||
if !already {
|
if !already {
|
||||||
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit)?;
|
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit).await?;
|
||||||
}
|
}
|
||||||
scan_soon(&state, Some(existing.id.clone())).await;
|
scan_soon(&state, Some(existing.id.clone())).await;
|
||||||
return Ok(Json(
|
return Ok(Json(
|
||||||
@@ -994,10 +1227,9 @@ async fn add_feed(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
|
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
state.ctx.db.subscribe(user.id, &id).await?;
|
||||||
state.ctx.db.subscribe(user.id, &id)?;
|
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
||||||
explicit_on_add(&state, user.id, &id, body.allow_explicit)?;
|
|
||||||
scan_soon(&state, Some(id.clone())).await;
|
scan_soon(&state, Some(id.clone())).await;
|
||||||
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
||||||
}
|
}
|
||||||
@@ -1024,11 +1256,14 @@ struct FeedPatch {
|
|||||||
schedule: Option<Option<String>>,
|
schedule: Option<Option<String>>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
folder: Option<Option<String>>,
|
folder: Option<Option<String>>,
|
||||||
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
|
category: Option<Option<String>>,
|
||||||
keywords: Option<Vec<String>>,
|
keywords: Option<Vec<String>>,
|
||||||
allow_explicit: Option<bool>,
|
allow_explicit: Option<bool>,
|
||||||
auto_download: Option<bool>,
|
auto_download: Option<bool>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
max_new_per_check: Option<Option<usize>>,
|
max_new_per_check: Option<Option<usize>>,
|
||||||
|
pinned: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
@@ -1045,13 +1280,19 @@ async fn patch_feed(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<FeedPatch>,
|
Json(body): Json<FeedPatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
|
||||||
|
if let Some(on) = body.pinned
|
||||||
|
&& !state.ctx.db.set_pinned(user.id, &id, on).await?
|
||||||
|
{
|
||||||
|
return Err(ApiError::not_found("you do not subscribe to that feed"));
|
||||||
|
}
|
||||||
// What one person wants -- which items, whether to fetch them, how many at a time --
|
// What one person wants -- which items, whether to fetch them, how many at a time --
|
||||||
// is theirs. It goes on their subscription and nobody else sees the change.
|
// is theirs. It goes on their subscription and nobody else sees the change.
|
||||||
if state.ctx.db.subscription(user.id, &id)?.is_some() {
|
if state.ctx.db.subscription(user.id, &id).await?.is_some() {
|
||||||
let mut mine = state
|
let mut mine = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.subscription(user.id, &id)?
|
.subscription(user.id, &id).await?
|
||||||
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
|
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
|
||||||
let mut touched = false;
|
let mut touched = false;
|
||||||
if let Some(v) = body.keywords.clone() {
|
if let Some(v) = body.keywords.clone() {
|
||||||
@@ -1071,19 +1312,20 @@ async fn patch_feed(
|
|||||||
touched = true;
|
touched = true;
|
||||||
}
|
}
|
||||||
if touched {
|
if touched {
|
||||||
state.ctx.db.set_subscription(user.id, &mine)?;
|
state.ctx.db.set_subscription(user.id, &mine).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rest describes the feed itself -- where its files land, its address, when it is
|
// The rest describes the feed itself -- where its files land, its address, when it is
|
||||||
// polled -- and there is one of those however many people read it.
|
// polled -- and there is one of those however many people read it.
|
||||||
let feed_level = body.url.is_some() || body.folder.is_some() || body.schedule.is_some();
|
let feed_level =
|
||||||
|
body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some();
|
||||||
if !feed_level {
|
if !feed_level {
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
if !user.is_admin {
|
if !user.is_admin {
|
||||||
return Err(ApiError::forbidden(
|
return Err(ApiError::forbidden(
|
||||||
"the feed's address, folder and schedule are the same for everyone, so only an admin changes them",
|
"the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut cfg = (*state.ctx.cfg()).clone();
|
let mut cfg = (*state.ctx.cfg()).clone();
|
||||||
@@ -1091,13 +1333,13 @@ async fn patch_feed(
|
|||||||
// Derived feeds have no config entry. Editing one is the moment it earns a real
|
// Derived feeds have no config entry. Editing one is the moment it earns a real
|
||||||
// entry: promote it, so the config holds your decisions and nothing else.
|
// entry: promote it, so the config holds your decisions and nothing else.
|
||||||
if !cfg.feeds.contains_key(&id) {
|
if !cfg.feeds.contains_key(&id) {
|
||||||
let subs = crate::subscriptions(&state.ctx)?;
|
let subs = crate::subscriptions(&state.ctx).await?;
|
||||||
let found = subs
|
let found = subs
|
||||||
.iter()
|
.iter()
|
||||||
.find(|s| s.id == id)
|
.find(|s| s.id == id)
|
||||||
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
||||||
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
||||||
state.ctx.db.unmanage(&id)?;
|
state.ctx.db.unmanage(&id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let checked = match &body.url {
|
let checked = match &body.url {
|
||||||
@@ -1130,12 +1372,14 @@ async fn patch_feed(
|
|||||||
if let Some(v) = body.folder {
|
if let Some(v) = body.folder {
|
||||||
feed.folder = v.filter(|s| !s.trim().is_empty());
|
feed.folder = v.filter(|s| !s.trim().is_empty());
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
if let Some(v) = body.category {
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
||||||
|
}
|
||||||
|
state.ctx.store_cfg(cfg).await?;
|
||||||
if url_changed {
|
if url_changed {
|
||||||
// Refreshing a rotated auth token is the common case; entries and download history
|
// Refreshing a rotated auth token is the common case; entries and download history
|
||||||
// are keyed by feed id, so they survive the change.
|
// are keyed by feed id, so they survive the change.
|
||||||
state.ctx.db.clear_validators(&id)?;
|
state.ctx.db.clear_validators(&id).await?;
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -1147,14 +1391,14 @@ async fn remove_feed(
|
|||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
|
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
|
||||||
// else's alone.
|
// else's alone.
|
||||||
state.ctx.db.unsubscribe(user.id, &id)?;
|
state.ctx.db.unsubscribe(user.id, &id).await?;
|
||||||
for child in crate::subscriptions(&state.ctx)?
|
for child in crate::subscriptions(&state.ctx).await?
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
||||||
{
|
{
|
||||||
state.ctx.db.unsubscribe(user.id, &child.id)?;
|
state.ctx.db.unsubscribe(user.id, &child.id).await?;
|
||||||
}
|
}
|
||||||
if state.ctx.db.subscriber_counts()?.contains_key(&id) {
|
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,12 +1408,11 @@ async fn remove_feed(
|
|||||||
if cfg.feeds.remove(&id).is_none() {
|
if cfg.feeds.remove(&id).is_none() {
|
||||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||||
// read unless you unsubscribe from the OPML itself.
|
// read unless you unsubscribe from the OPML itself.
|
||||||
state.ctx.db.drop_managed(&id)?;
|
state.ctx.db.drop_managed(&id).await?;
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
crate::retire_group(&state.ctx, &id).await?;
|
||||||
crate::retire_group(&state.ctx, &id)?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,10 +1430,10 @@ async fn set_flags(
|
|||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
use crate::db::EntryFlag;
|
use crate::db::EntryFlag;
|
||||||
if let Some(v) = body.read {
|
if let Some(v) = body.read {
|
||||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v)?;
|
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
|
||||||
}
|
}
|
||||||
if let Some(v) = body.flagged {
|
if let Some(v) = body.flagged {
|
||||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v)?;
|
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -1205,12 +1448,12 @@ async fn download_now(
|
|||||||
let enc = state
|
let enc = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.enclosure(id)?
|
.enclosure(id).await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||||
if enc.path.is_some() {
|
if enc.path.is_some() {
|
||||||
return Ok(StatusCode::NO_CONTENT); // Already here.
|
return Ok(StatusCode::NO_CONTENT); // Already here.
|
||||||
}
|
}
|
||||||
state.ctx.db.requeue(id)?;
|
state.ctx.db.requeue(id).await?;
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Download { enclosure: id })
|
.send(Command::Download { enclosure: id })
|
||||||
@@ -1234,20 +1477,20 @@ async fn delete_file(
|
|||||||
let enc = state
|
let enc = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.enclosure(id)?
|
.enclosure(id).await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||||
|
|
||||||
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
|
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
|
||||||
// it, once, and let them decide.
|
// it, once, and let them decide.
|
||||||
if !q.force {
|
if !q.force {
|
||||||
let (starred, unread) = state.ctx.db.others_wanting(id, user.id)?;
|
let (starred, unread) = state.ctx.db.others_wanting(id, user.id).await?;
|
||||||
let people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") };
|
let people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") };
|
||||||
let complaint = match (starred, unread) {
|
let complaint = match (starred, unread) {
|
||||||
(0, 0) => None,
|
(0, 0) => None,
|
||||||
(0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })),
|
(0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })),
|
||||||
(st, 0) => Some(format!("another {} kept it", people(st))),
|
(st, 0) => Some(format!("another {} pinned it", people(st))),
|
||||||
(st, u) => Some(format!(
|
(st, u) => Some(format!(
|
||||||
"another {} kept it, and {} not played it yet",
|
"another {} pinned it, and {} not played it yet",
|
||||||
people(st),
|
people(st),
|
||||||
if u == 1 { "one person has".to_string() } else { format!("{u} have") }
|
if u == 1 { "one person has".to_string() } else { format!("{u} have") }
|
||||||
)),
|
)),
|
||||||
@@ -1266,7 +1509,7 @@ async fn delete_file(
|
|||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
|
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
|
||||||
state.ctx.db.mark_reaped(id)?;
|
state.ctx.db.mark_reaped(id).await?;
|
||||||
state.events.send(Event::Reaped {
|
state.events.send(Event::Reaped {
|
||||||
path: enc.path.unwrap_or_default(),
|
path: enc.path.unwrap_or_default(),
|
||||||
bytes: enc.length.unwrap_or(0).max(0) as u64,
|
bytes: enc.length.unwrap_or(0).max(0) as u64,
|
||||||
@@ -1320,7 +1563,7 @@ async fn media(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
req: Request,
|
req: Request,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let Ok(Some(enc)) = state.ctx.db.enclosure(id) else {
|
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
|
||||||
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
|
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
|
||||||
};
|
};
|
||||||
let Some(path) = enc.path else {
|
let Some(path) = enc.path else {
|
||||||
@@ -1335,6 +1578,8 @@ async fn media(
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Position {
|
struct Position {
|
||||||
secs: i64,
|
secs: i64,
|
||||||
|
/// The length the player measured, for an episode whose feed gives none.
|
||||||
|
duration: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_position(
|
async fn set_position(
|
||||||
@@ -1343,7 +1588,7 @@ async fn set_position(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<Position>,
|
Json(body): Json<Position>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs)?;
|
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1355,12 +1600,12 @@ async fn read_all(
|
|||||||
// A subscription's own row has no entries, so marking it read means everything under it.
|
// A subscription's own row has no entries, so marking it read means everything under it.
|
||||||
let mut ids = vec![id.clone()];
|
let mut ids = vec![id.clone()];
|
||||||
ids.extend(
|
ids.extend(
|
||||||
crate::subscriptions(&state.ctx)?
|
crate::subscriptions(&state.ctx).await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
||||||
.map(|s| s.id),
|
.map(|s| s.id),
|
||||||
);
|
);
|
||||||
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
|
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
|
||||||
Ok(Json(serde_json::json!({ "marked": n })))
|
Ok(Json(serde_json::json!({ "marked": n })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,8 +1616,8 @@ async fn read_all_mine(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let ids: Vec<String> =
|
let ids: Vec<String> =
|
||||||
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
|
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
|
||||||
Ok(Json(serde_json::json!({ "marked": n })))
|
Ok(Json(serde_json::json!({ "marked": n })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1393,9 +1638,9 @@ async fn download_latest(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(body): Json<HowMany>,
|
Json(body): Json<HowMany>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100))?;
|
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100)).await?;
|
||||||
for enc in &ids {
|
for enc in &ids {
|
||||||
state.ctx.db.requeue(*enc)?;
|
state.ctx.db.requeue(*enc).await?;
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Download { enclosure: *enc })
|
.send(Command::Download { enclosure: *enc })
|
||||||
@@ -1413,7 +1658,7 @@ async fn export_opml(
|
|||||||
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are
|
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are
|
||||||
// not yours to download. This used to export config.toml to whoever asked.
|
// not yours to download. This used to export config.toml to whoever asked.
|
||||||
let mine: std::collections::HashSet<String> =
|
let mine: std::collections::HashSet<String> =
|
||||||
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let mut doc = opml::OPML {
|
let mut doc = opml::OPML {
|
||||||
head: Some(opml::Head {
|
head: Some(opml::Head {
|
||||||
title: Some("ipx subscriptions".into()),
|
title: Some("ipx subscriptions".into()),
|
||||||
@@ -1421,7 +1666,7 @@ async fn export_opml(
|
|||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
for s in crate::subscriptions(&state.ctx)? {
|
for s in crate::subscriptions(&state.ctx).await? {
|
||||||
// A feed from an OPML subscription comes back with the OPML itself.
|
// A feed from an OPML subscription comes back with the OPML itself.
|
||||||
if s.managed || !mine.contains(&s.id) {
|
if s.managed || !mine.contains(&s.id) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1429,7 +1674,7 @@ async fn export_opml(
|
|||||||
let title = state
|
let title = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.feed_summary(&s.id)
|
.feed_summary(&s.id).await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|sum| sum.title)
|
.and_then(|sum| sum.title)
|
||||||
.unwrap_or_else(|| s.id.clone());
|
.unwrap_or_else(|| s.id.clone());
|
||||||
@@ -1463,7 +1708,7 @@ async fn import_opml(
|
|||||||
// file arrives as text, is read here, and is gone when the request ends.
|
// file arrives as text, is read here, and is gone when the request ends.
|
||||||
let doc = opml::OPML::from_str(&body.xml)
|
let doc = opml::OPML::from_str(&body.xml)
|
||||||
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
|
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
|
||||||
let (added, already) = crate::subscribe_opml(&state.ctx, &state.config_path, &doc, user.id)?;
|
let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?;
|
||||||
if added > 0 {
|
if added > 0 {
|
||||||
scan_soon(&state, None).await;
|
scan_soon(&state, None).await;
|
||||||
}
|
}
|
||||||
@@ -1537,8 +1782,7 @@ async fn patch_settings(
|
|||||||
if let Some(v) = body.max_age_days {
|
if let Some(v) = body.max_age_days {
|
||||||
cfg.general.max_age_days = v;
|
cfg.general.max_age_days = v;
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<description>A synthetic feed used by the parser tests.</description>
|
<description>A synthetic feed used by the parser tests.</description>
|
||||||
<ttl>45</ttl>
|
<ttl>45</ttl>
|
||||||
<itunes:explicit>no</itunes:explicit>
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:category text="Technology"><itunes:category text="Podcasting"/></itunes:category>
|
||||||
|
<itunes:category text="News"/>
|
||||||
|
|
||||||
<item>
|
<item>
|
||||||
<title>Episode One</title>
|
<title>Episode One</title>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Executes web/index.html's script against a stub DOM and fails on anything thrown.
|
// Builds a page from web/src as build.rs does, runs its script against a stub DOM, and fails
|
||||||
|
// on anything thrown: web/index.html, then web/admin.html in a second run of this file.
|
||||||
//
|
//
|
||||||
// This exists because a ReferenceError at load once blanked the whole UI: a patch
|
// This exists because a ReferenceError at load once blanked the whole UI: a patch
|
||||||
// anchored on a function that no longer existed, so `prefsModal` was referenced but
|
// anchored on a function that no longer existed, so `prefsModal` was referenced but
|
||||||
@@ -6,13 +7,20 @@
|
|||||||
// and every server-side test passed too, because the server was fine.
|
// and every server-side test passed too, because the server was fine.
|
||||||
//
|
//
|
||||||
// node tests/page-smoke.js
|
// node tests/page-smoke.js
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
|
const PAGE = process.argv[2] || 'index.html';
|
||||||
|
|
||||||
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8');
|
const { buildPage } = require('../web/build.mjs');
|
||||||
const script = html.split('<script>')[1].split('</script>')[0];
|
// What ships: minified, so an id may have lost its quotes.
|
||||||
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
|
const { html, js: script, script: file } = buildPage(PAGE);
|
||||||
|
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
|
||||||
|
console.error(`FAIL: ${PAGE} does not load /app.css?v=<hash>`); process.exit(1);
|
||||||
|
}
|
||||||
|
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
||||||
|
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
||||||
|
}
|
||||||
|
// Ids in the page, and in the markup the script builds for its dialogs.
|
||||||
|
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
||||||
|
|
||||||
const missing = [];
|
const missing = [];
|
||||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||||
@@ -60,11 +68,12 @@ const ctx = {
|
|||||||
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
||||||
};
|
};
|
||||||
ctx.globalThis = ctx;
|
ctx.globalThis = ctx;
|
||||||
ctx.window.location = { href: '' };
|
ctx.window.location = { href: '', hash: '' };
|
||||||
|
ctx.location = ctx.window.location;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
vm.runInContext(script, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
vm.runInContext(script, ctx, { filename: `${PAGE}<script>`, timeout: 5000 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
|
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -78,18 +87,22 @@ const feed = {
|
|||||||
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
||||||
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
||||||
};
|
};
|
||||||
const drive = [
|
const drive = PAGE === 'admin.html' ? [
|
||||||
|
['drawServer', () => ctx.drawServer()],
|
||||||
|
['drawAccounts', () => ctx.drawAccounts()],
|
||||||
|
['drawLogView', () => ctx.drawLogView()],
|
||||||
|
] : [
|
||||||
['settingsModal', () => ctx.settingsModal(feed)],
|
['settingsModal', () => ctx.settingsModal(feed)],
|
||||||
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
|
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
|
||||||
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
||||||
['removeFeed', () => ctx.removeFeed(feed)],
|
['removeFeed', () => ctx.removeFeed(feed)],
|
||||||
['prefsModal', () => ctx.prefsModal()],
|
['prefsModal', () => ctx.prefsModal()],
|
||||||
['usersModal', () => ctx.usersModal()],
|
|
||||||
['opmlModal', () => ctx.opmlModal()],
|
['opmlModal', () => ctx.opmlModal()],
|
||||||
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
||||||
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
||||||
|
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
||||||
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
||||||
['logsModal', () => ctx.logsModal()],
|
['keysModal', () => ctx.keysModal()],
|
||||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||||
// of a vm context the way var and function declarations do.
|
// of a vm context the way var and function declarations do.
|
||||||
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
||||||
@@ -106,10 +119,18 @@ for (const [name, fn] of drive) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
||||||
|
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
||||||
|
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
||||||
|
missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel)));
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('OK: page script loads clean, every selector it wires at load exists');
|
console.log(`OK: ${PAGE}: its script loads clean, every selector it wires at load exists`);
|
||||||
// logsModal arms a poll timer; without this the pending interval keeps node alive.
|
// The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
|
||||||
|
if (PAGE === 'index.html') {
|
||||||
|
const r = require('child_process').spawnSync(process.execPath, [__filename, 'admin.html'], { stdio: 'inherit' });
|
||||||
|
process.exit(r.status);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -20,62 +20,130 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
|||||||
expect(errors, 'the page script must not throw at load').toEqual([]);
|
expect(errors, 'the page script must not throw at load').toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme toggle actually changes the theme', async ({ page }) => {
|
test('the script is its own file, cached until a deploy changes it', async ({ page }) => {
|
||||||
// Regression: this button was wired after a line that threw, so it did nothing.
|
const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js');
|
||||||
const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system');
|
const doc = await page.reload();
|
||||||
await page.locator('#theme').click();
|
const r = await js;
|
||||||
await expect
|
// The page is checked every visit, so it always names the current script...
|
||||||
.poll(() => page.evaluate(() => document.documentElement.dataset.theme))
|
expect(doc.headers()['cache-control']).toBe('no-cache');
|
||||||
.not.toBe(before);
|
// ...by a hash of its contents, which is why the script itself can be kept for a year.
|
||||||
|
expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
|
||||||
|
expect(r.headers()['cache-control']).toContain('immutable');
|
||||||
|
expect(r.headers()['content-type']).toContain('javascript');
|
||||||
|
expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0);
|
||||||
|
// The sign-in page's script loads before signing in.
|
||||||
|
const login = await page.request.get('/login.js', { headers: { cookie: '' } });
|
||||||
|
expect(login.status()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme button steps through dark, light and classic, and remembers', async ({ page }) => {
|
test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
|
||||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
|
||||||
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
|
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||||
expect(await theme()).toBe('classic');
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/);
|
await expect(page.locator('#stheme')).toHaveValue((await root())[0]);
|
||||||
|
|
||||||
|
await page.locator('#stheme').selectOption('modern');
|
||||||
|
await page.locator('#smode').selectOption('auto');
|
||||||
|
// Auto follows the system, live, with no reload.
|
||||||
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
|
await expect.poll(root).toEqual(['modern', 'light']);
|
||||||
|
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg
|
||||||
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await expect.poll(root).toEqual(['modern', 'dark']);
|
||||||
|
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg
|
||||||
|
|
||||||
|
// Dracula, then its light half, Alucard.
|
||||||
|
await page.locator('#stheme').selectOption('dracula');
|
||||||
|
await page.locator('#smode').selectOption('dark');
|
||||||
|
await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36
|
||||||
|
await page.locator('#smode').selectOption('light');
|
||||||
|
await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB
|
||||||
|
|
||||||
|
// Classic and Paper come one way only, so there is nothing to choose.
|
||||||
|
await page.locator('#stheme').selectOption('paper');
|
||||||
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
|
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
||||||
|
// The save that says Classic: the ones before it may still be answering.
|
||||||
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
|
||||||
|
&& r.request().postDataJSON().theme === 'classic');
|
||||||
|
await page.locator('#stheme').selectOption('classic');
|
||||||
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
|
await saved; // kept on the account, not the browser
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect.poll(theme).toBe('classic');
|
await expect.poll(root).toEqual(['classic', 'light']);
|
||||||
// The 2004 Mac app set its type in Lucida Grande.
|
// The 2004 Mac app set its type in Lucida Grande.
|
||||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||||
|
expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0);
|
||||||
|
|
||||||
|
// Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one.
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await page.locator('#stheme').selectOption('nordic');
|
||||||
|
await expect(page.locator('#smode')).toHaveValue('light');
|
||||||
|
await page.locator('#smode').selectOption('dark');
|
||||||
|
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
|
test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => {
|
||||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
|
||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#stheme')).toHaveValue(await theme());
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
|
await page.locator('#stheme').selectOption('flatremix');
|
||||||
|
expect((await saved).status()).toBe(204);
|
||||||
|
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
|
await page.locator('#smode').selectOption('light');
|
||||||
|
await saved2;
|
||||||
|
|
||||||
await page.locator('#stheme').selectOption('auto');
|
// Another browser: nothing in its localStorage, and the page still arrives in the theme,
|
||||||
await expect.poll(theme).toBe('auto');
|
// written onto <html> by the server rather than set once the script has run.
|
||||||
// Auto follows the system; emulating a light system must show the light palette live,
|
const other = await browser.newContext();
|
||||||
// no reload needed, since it is a media query rather than something JS picks per click.
|
const p2 = await other.newPage();
|
||||||
await page.emulateMedia({ colorScheme: 'light' });
|
const res = await p2.goto(`/?token=${TOKEN}`);
|
||||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
|
||||||
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
|
expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
||||||
await page.emulateMedia({ colorScheme: 'dark' });
|
.toEqual(['flatremix', 'light']);
|
||||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
await other.close();
|
||||||
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
|
|
||||||
|
|
||||||
// The header button and the dropdown are the same one setting, not two.
|
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
|
|
||||||
await page.locator('#theme').click();
|
|
||||||
expect(await theme()).toBe('dark');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings opens and saves the global schedule', async ({ page }) => {
|
test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => {
|
||||||
await page.locator('#prefs').click();
|
// A new account, made by the proxy header on first sight, so it has no theme of its own yet.
|
||||||
await expect(page.locator('#modal.on')).toBeVisible();
|
const who = `theme-${Date.now()}@example.com`;
|
||||||
await expect(page.locator('#gnum')).toBeVisible();
|
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
||||||
|
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
|
||||||
|
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
|
await page.goto('/');
|
||||||
|
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
||||||
|
.toEqual(['modern', 'light']);
|
||||||
|
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' });
|
||||||
|
await ctx.close();
|
||||||
|
|
||||||
|
// Anywhere else now, it comes from the account.
|
||||||
|
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
||||||
|
const p2 = await fresh.newPage();
|
||||||
|
const res = await p2.goto('/');
|
||||||
|
expect(await res.text()).toContain('data-theme=modern data-choice=light');
|
||||||
|
await fresh.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin page saves the global schedule', async ({ page }) => {
|
||||||
|
// Reached from the header's Admin link, and on its own page, not in Settings (issue #19).
|
||||||
|
await page.locator('#admin').click();
|
||||||
|
await expect(page).toHaveURL(/\/admin$/);
|
||||||
|
await expect(page.locator('#atabs a.on')).toHaveText('Server');
|
||||||
await page.locator('#gnum').fill('4');
|
await page.locator('#gnum').fill('4');
|
||||||
await page.locator('#gunit').selectOption('h');
|
await page.locator('#gunit').selectOption('h');
|
||||||
await page.locator('#gsave').click();
|
await page.locator('#gsave').click();
|
||||||
await expect(page.locator('#modal.on')).toBeHidden();
|
await expect(page.locator('#toasts')).toContainText('Settings saved');
|
||||||
|
|
||||||
// It must survive a reload, i.e. actually reach the config.
|
// It must survive a reload, i.e. actually reach the config.
|
||||||
await page.locator('#prefs').click();
|
await page.reload();
|
||||||
await expect(page.locator('#gnum')).toHaveValue('4');
|
await expect(page.locator('#gnum')).toHaveValue('4');
|
||||||
await expect(page.locator('#gunit')).toHaveValue('h');
|
await expect(page.locator('#gunit')).toHaveValue('h');
|
||||||
|
// And Settings in the app no longer has it.
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await expect(page.locator('#modalCard')).toContainText('Theme');
|
||||||
|
await expect(page.locator('#gnum')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
||||||
@@ -120,6 +188,35 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
|||||||
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
|
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('while an episode plays, its play buttons all say pause, and pause it', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
|
||||||
|
await expect(downloaded).toBeVisible({ timeout: 20_000 });
|
||||||
|
await downloaded.click();
|
||||||
|
// What the buttons do, not whether this browser decodes the fixture: it does not, reliably,
|
||||||
|
// and a load error pauses the player, rightly turning every button back to play. So the
|
||||||
|
// player plays and pauses here as a real one does, events and all, with no file behind it.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let paused = true;
|
||||||
|
Object.defineProperty(audio, 'paused', { get: () => paused, configurable: true });
|
||||||
|
audio.play = async () => { paused = false; audio.dispatchEvent(new Event('play')); };
|
||||||
|
audio.pause = () => { paused = true; audio.dispatchEvent(new Event('pause')); };
|
||||||
|
});
|
||||||
|
const pane = page.locator('#files [data-a="play"]');
|
||||||
|
await pane.click();
|
||||||
|
await expect.poll(() => page.evaluate(() => !audio.paused)).toBe(true);
|
||||||
|
// The files pane, the row and the toolbar all follow the player bar, not only the bar.
|
||||||
|
await expect(pane).toHaveAttribute('title', 'Pause');
|
||||||
|
await expect(downloaded.locator('[data-a="play"]')).toHaveAttribute('title', 'Pause');
|
||||||
|
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Pause');
|
||||||
|
await pane.click(); // and pressing it pauses
|
||||||
|
await expect.poll(() => page.evaluate(() => audio.paused)).toBe(true);
|
||||||
|
await expect(pane).toHaveAttribute('title', 'Play');
|
||||||
|
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Play the selected item');
|
||||||
|
await page.locator('#pclose').click();
|
||||||
|
});
|
||||||
|
|
||||||
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
||||||
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
||||||
// header image rendered as a broken player.
|
// header image rendered as a broken player.
|
||||||
@@ -155,30 +252,69 @@ test('an item with several enclosures lists them all', async ({ page }) => {
|
|||||||
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Currently Listening, below Popular, resumes an episode you started', async ({ page }) => {
|
test('Currently Listening, its own place below Popular, resumes an episode you started or forgets it', async ({ page }) => {
|
||||||
// Second Episode (900s) is 42 seconds in and unfinished. Which of Test Show's two episodes
|
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
|
||||||
// the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier
|
// and opening marks it read; it is listed all the same, because read is not finished. This
|
||||||
// test may have opened -- and so read -- this one already; reset it before relying on it.
|
// test used to set it unread first, which hid exactly the bug in issue #14.
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: false }) }));
|
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
||||||
|
|
||||||
|
// Popular lists feeds and nothing else; the episodes have a place of their own under it.
|
||||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||||
|
await expect(page.locator('#popular')).toBeVisible();
|
||||||
|
await expect(page.locator('#listening')).toHaveCount(0);
|
||||||
|
const places = await page.locator('#feedlist .place b').allTextContents();
|
||||||
|
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Popular') + 1);
|
||||||
|
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
||||||
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(row).toContainText('0:42 of 15:00');
|
await expect(row).toContainText('14:18 left');
|
||||||
|
|
||||||
|
// Removing it forgets where you got to, so it is still gone on the next visit.
|
||||||
|
await row.locator('[data-a=remove]').click();
|
||||||
|
await expect(row).toHaveCount(0);
|
||||||
|
await expect(page.locator('#player')).not.toBeVisible();
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
||||||
|
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
|
||||||
|
|
||||||
|
// Started again, it is back, and clicking the row resumes it. Finishing it (90%) is
|
||||||
|
// covered in the Rust tests; here the player's own save on close would race it.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
||||||
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await row.click();
|
await row.click();
|
||||||
await expect(page.locator('#player')).toBeVisible();
|
await expect(page.locator('#player')).toBeVisible();
|
||||||
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
||||||
|
// The row in the player carries the EQ bars, as the feed view's does, until the player closes.
|
||||||
|
await expect(row).toHaveClass(/\bnow\b/);
|
||||||
|
await expect(row.locator('.eq')).toBeVisible();
|
||||||
await page.locator('#pclose').click();
|
await page.locator('#pclose').click();
|
||||||
|
await expect(row).not.toHaveClass(/\bnow\b/);
|
||||||
|
await expect(row.locator('.eq')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
// Finished (read) drops it from the list, however far it got.
|
test('a player nobody has played since it last saved does not save again', async ({ page }) => {
|
||||||
await page.evaluate(() =>
|
// A tab left paused at 41:15 saved that as it reloaded, over the 32:48 another had reached,
|
||||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
|
// and the episode dropped out of Currently Listening. The fixture audio does not decode, so
|
||||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
// this stands in for a loaded file paused at 2 seconds and counts what savePos sends.
|
||||||
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
|
const sent = await page.evaluate(() => {
|
||||||
|
Object.defineProperty(audio, 'readyState', { get: () => 4 });
|
||||||
|
Object.defineProperty(audio, 'currentTime', { get: () => 2, set() {} });
|
||||||
|
let n = 0;
|
||||||
|
navigator.sendBeacon = () => (n++, true);
|
||||||
|
player.guid = 'ui-2'; player.feed = 'test-show'; player.entry = null; player.moved = false;
|
||||||
|
savePos(); // what a reload, a pause or the close button calls
|
||||||
|
const idle = n;
|
||||||
|
player.moved = true; // what playing sets
|
||||||
|
savePos();
|
||||||
|
savePos(); // and once saved, it is idle again
|
||||||
|
return [idle, n];
|
||||||
|
});
|
||||||
|
expect(sent).toEqual([0, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the filter tabs change what is listed', async ({ page }) => {
|
test('the filter tabs change what is listed', async ({ page }) => {
|
||||||
@@ -190,10 +326,36 @@ test('the filter tabs change what is listed', async ({ page }) => {
|
|||||||
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
||||||
|
|
||||||
await page.locator('.tabs button', { hasText: 'Kept' }).first().click();
|
await page.locator('.tabs button', { hasText: 'Pinned' }).first().click();
|
||||||
await expect(page.locator('#count')).toContainText('0 items');
|
await expect(page.locator('#count')).toContainText('0 items');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on the Unread tab an item stays while you read it and goes when you move on', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
// Earlier tests read things; make the first two unread with their own dots.
|
||||||
|
for (const i of [0, 1]) {
|
||||||
|
const row = page.locator('.ep').nth(i);
|
||||||
|
if (await row.evaluate(r => r.classList.contains('read'))) {
|
||||||
|
await row.locator('[data-a="read"]').click();
|
||||||
|
await expect(page.locator('.ep').nth(i)).not.toHaveClass(/\bread\b/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
|
const first = page.locator('.ep').first();
|
||||||
|
const guid = await first.getAttribute('data-guid');
|
||||||
|
await first.click();
|
||||||
|
const it = page.locator(`.ep[data-guid="${guid}"]`);
|
||||||
|
await expect(it).toHaveClass(/\bread\b/);
|
||||||
|
await page.waitForTimeout(1500); // past the SSE refresh debounce and loadFeeds
|
||||||
|
await expect(it).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.ep').nth(1).click();
|
||||||
|
await expect(it).toHaveCount(0);
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
});
|
||||||
|
|
||||||
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await page.locator('#content .acts [data-a="settings"]').click();
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
@@ -209,7 +371,7 @@ test('a feed URL is editable and has a copy button', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
|
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
|
||||||
await page.locator('#logs').click();
|
await page.goto('/admin#log');
|
||||||
await expect(page.locator('#logbox')).toBeVisible();
|
await expect(page.locator('#logbox')).toBeVisible();
|
||||||
await expect(page.locator('#logtabs button')).toHaveCount(4);
|
await expect(page.locator('#logtabs button')).toHaveCount(4);
|
||||||
|
|
||||||
@@ -407,18 +569,22 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
|
|
||||||
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
||||||
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
||||||
// Settings stays: Sam has their own subscriptions to export and import, and the
|
// Settings stays: Sam has their own theme and subscriptions. The admin page -- the server's
|
||||||
// schedule and quota are worth seeing even without a say in them. Only the log and the
|
// settings, the accounts and the log -- is an admin's alone, and Sam is not even sent the
|
||||||
// users screen -- and the server -- are an admin's alone.
|
// link to it, never mind the page.
|
||||||
await expect(page.locator('#prefs')).toBeVisible();
|
await expect(page.locator('#prefs')).toBeVisible();
|
||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||||
await expect(page.locator('#gsave')).toBeHidden();
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await expect(page.locator('#gusers')).toBeHidden();
|
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click();
|
await page.locator('#modalCard .cardacts .btn').first().click();
|
||||||
// Hiding the button is not the guard; the server is.
|
await expect(page.locator('#admin')).toHaveCount(0);
|
||||||
|
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
||||||
|
// Asking for it anyway goes back to the app, and its script is refused.
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page).toHaveURL(/:8791\/$/);
|
||||||
|
expect((await page.request.get('/admin.js')).status()).toBe(403);
|
||||||
|
// Hiding the way in is not the guard; the server is.
|
||||||
expect((await page.request.get('/api/users')).status()).toBe(403);
|
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||||
await expect(page.locator('#logs')).toBeHidden();
|
|
||||||
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
||||||
|
|
||||||
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
||||||
@@ -474,11 +640,12 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
|||||||
|
|
||||||
// Every row says "Admin" on its checkbox, so match the name exactly.
|
// Every row says "Admin" on its checkbox, so match the name exactly.
|
||||||
const userRow = (page, name) =>
|
const userRow = (page, name) =>
|
||||||
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
page.locator('#accounts [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
||||||
|
|
||||||
async function openUsers(page) {
|
async function openUsers(page) {
|
||||||
await page.locator('#prefs').click();
|
await page.goto('/admin');
|
||||||
await page.locator('#gusers').click();
|
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin#accounts$/);
|
||||||
await expect(userRow(page, 'admin')).toBeVisible();
|
await expect(userRow(page, 'admin')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,19 +862,47 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
|||||||
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
||||||
expect(ids).not.toContain('paid-show');
|
expect(ids).not.toContain('paid-show');
|
||||||
|
|
||||||
// Subscribe from the directory this time; the popular list shares the same rows.
|
// Subscribe from the directory this time: the same feeds, as a grid of cover art.
|
||||||
|
const tiles = piper.locator('#popular .tile');
|
||||||
|
const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
|
||||||
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
|
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
|
||||||
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
||||||
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
||||||
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
|
||||||
|
// Two filters that combine: what a feed is, and what it is about.
|
||||||
|
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
|
||||||
|
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
|
||||||
|
await pick('tabs', 'Blogs').click();
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
|
||||||
|
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
|
||||||
|
// No empty chips: no blog here names Technology, so Blogs does not offer it.
|
||||||
|
await expect(pick('chips', 'Technology')).toHaveCount(0);
|
||||||
|
await pick('tabs', 'Podcasts').click();
|
||||||
|
await pick('chips', 'Technology').click();
|
||||||
|
await expect(pick('chips', 'Technology')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length);
|
||||||
|
// A second press lifts the chip and leaves the kind as it was.
|
||||||
|
await pick('chips', 'Technology').click();
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
|
||||||
|
await pick('tabs', 'All').click();
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
|
||||||
|
// Add a feed is for an address; Popular and Directory are where you browse (issue #30).
|
||||||
|
await piper.locator('#addFeed').click();
|
||||||
|
await expect(piper.locator('#nurl')).toBeVisible();
|
||||||
|
await expect(piper.locator('#modalCard .childrow')).toHaveCount(0);
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
await piper.locator('#modalCard button[title="Cancel"]').click();
|
||||||
|
|
||||||
const row = async () =>
|
const row = async () =>
|
||||||
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
||||||
const before = await row();
|
const before = await row();
|
||||||
expect(before.subscribed).toBe(false);
|
expect(before.subscribed).toBe(false);
|
||||||
await offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
||||||
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
||||||
@@ -753,13 +948,14 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
|
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
|
||||||
const icon = loc => loc.locator('svg path').first().getAttribute('d');
|
// The whole glyph, not just its path: pinned and not pinned share one outline and differ in fill.
|
||||||
|
const icon = loc => loc.locator('svg').first().innerHTML();
|
||||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
|
||||||
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
|
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
|
||||||
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
|
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
|
||||||
|
|
||||||
// The toolbar's read and keep show the selected item's state, as its own buttons do, and follow
|
// The toolbar's read and pin show the selected item's state, as its own buttons do, and follow
|
||||||
// a change made from the toolbar.
|
// a change made from the toolbar.
|
||||||
await page.locator('.ep').first().click();
|
await page.locator('.ep').first().click();
|
||||||
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
|
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
|
||||||
@@ -775,9 +971,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
|
|||||||
const dialogs = [
|
const dialogs = [
|
||||||
() => page.locator('#addFeed').click(),
|
() => page.locator('#addFeed').click(),
|
||||||
() => page.locator('#prefs').click(),
|
() => page.locator('#prefs').click(),
|
||||||
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
|
|
||||||
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
|
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
|
||||||
() => page.locator('#logs').click(),
|
|
||||||
() => page.locator('#content .acts [data-a="settings"]').click(),
|
() => page.locator('#content .acts [data-a="settings"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="rm"]').click(),
|
() => page.locator('#content .acts [data-a="rm"]').click(),
|
||||||
@@ -802,6 +996,9 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
|
|||||||
// its own button makes it unread again.
|
// its own button makes it unread again.
|
||||||
await page.locator('.ep').first().click();
|
await page.locator('.ep').first().click();
|
||||||
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
|
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
|
||||||
|
// The button turns only once the server has it. The badge was no proof: it was seldom 0 to
|
||||||
|
// begin with, and a mark-unread still in flight could land after the read-all below.
|
||||||
|
await expect(page.locator('#detail [data-a="read"][title="Mark read"]')).toBeVisible();
|
||||||
await expect(all.locator('.badge')).not.toHaveText('0');
|
await expect(all.locator('.badge')).not.toHaveText('0');
|
||||||
|
|
||||||
page.once('dialog', d => d.accept());
|
page.once('dialog', d => d.accept());
|
||||||
@@ -884,3 +1081,221 @@ test('someone the proxy signs in never sees the password page, and signs out thr
|
|||||||
await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
|
await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keys move through items and places, after Feedly', async ({ page }) => {
|
||||||
|
// Last in the file: selecting an item marks it read, which would change what later tests see.
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
const rows = page.locator('#eps .ep');
|
||||||
|
await expect(rows.nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
const sel = page.locator('#eps .ep.sel');
|
||||||
|
const guid = i => rows.nth(i).getAttribute('data-guid');
|
||||||
|
await page.keyboard.press('j');
|
||||||
|
await expect(sel).toHaveAttribute('data-guid', await guid(0));
|
||||||
|
await page.keyboard.press('j');
|
||||||
|
await expect(sel).toHaveAttribute('data-guid', await guid(1));
|
||||||
|
await page.keyboard.press('k');
|
||||||
|
await expect(sel).toHaveAttribute('data-guid', await guid(0));
|
||||||
|
|
||||||
|
// g and a letter go somewhere; typed into a box, the same letters are only text.
|
||||||
|
await page.keyboard.press('g');
|
||||||
|
await page.keyboard.press('d');
|
||||||
|
await expect(page.locator('#count')).toContainText('Directory');
|
||||||
|
await page.locator('#epSearch').focus();
|
||||||
|
await page.keyboard.type('ga');
|
||||||
|
await expect(page.locator('#count')).toContainText('Directory');
|
||||||
|
await page.locator('#epSearch').fill('');
|
||||||
|
await page.locator('#epSearch').blur();
|
||||||
|
|
||||||
|
await page.keyboard.press('?');
|
||||||
|
await expect(page.locator('#modalCard')).toContainText('Keyboard shortcuts');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.keyboard.press('g');
|
||||||
|
await page.keyboard.press('a');
|
||||||
|
await expect(page.locator('#count')).toContainText('All Subscriptions');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an admin can give a blog its Directory category', async ({ page }) => {
|
||||||
|
const patch = (id, category) => page.evaluate(([id, category]) =>
|
||||||
|
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
|
||||||
|
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
|
||||||
|
|
||||||
|
await patch('picture-blog', 'Visual Arts');
|
||||||
|
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Visual Arts' });
|
||||||
|
// A feed's own iTunes category wins over one given here.
|
||||||
|
await patch('test-show', 'Comedy');
|
||||||
|
expect((await listed('test-show')).category).toBe('Technology');
|
||||||
|
|
||||||
|
for (const id of ['picture-blog', 'test-show']) await patch(id, null);
|
||||||
|
expect((await listed('picture-blog')).category).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
const boxes = {
|
||||||
|
headCell: await page.locator('.ephead [data-sort="kept"]').boundingBox(),
|
||||||
|
headIcon: await page.locator('.ephead [data-sort="kept"] svg').first().boundingBox(),
|
||||||
|
rowCell: await page.locator('#eps .ep .fl').first().boundingBox(),
|
||||||
|
rowIcon: await page.locator('#eps .ep .fl svg').first().boundingBox(),
|
||||||
|
};
|
||||||
|
const mid = b => b.x + b.width / 2;
|
||||||
|
expect(Math.abs(mid(boxes.headIcon) - mid(boxes.rowIcon)), JSON.stringify(boxes)).toBeLessThan(1);
|
||||||
|
|
||||||
|
// From ipx itself, not a font service.
|
||||||
|
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
|
||||||
|
expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
test('a downloaded file can be deleted, from above the show notes', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).first().click();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const del = page.locator('#detail [data-a="del"]');
|
||||||
|
await expect(del).toBeInViewport();
|
||||||
|
// Below a long set of notes it was screens down and looked missing (issue #21).
|
||||||
|
expect(await page.locator('#detail').evaluate(d =>
|
||||||
|
!!(d.querySelector('.encbox').compareDocumentPosition(d.querySelector('.dbody')) & Node.DOCUMENT_POSITION_FOLLOWING)))
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file not yet downloaded has its icon in line with the rest of its row', async ({ page }) => {
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||||
|
await expect(page.locator('.ep .dlbar').first()).toBeAttached({ timeout: 20_000 });
|
||||||
|
// The icon's middle against the date's, downloaded or not. The download bar used to take a
|
||||||
|
// line of its own and lift the icon of every pending file (issue #31).
|
||||||
|
const offsets = await page.$$eval('.ep', rows => rows.map(r => {
|
||||||
|
const k = r.querySelector('.file .kind'), d = r.querySelector('.date');
|
||||||
|
if (!k || !d) return null;
|
||||||
|
const a = k.getBoundingClientRect(), b = d.getBoundingClientRect();
|
||||||
|
return Math.round((a.top + a.height / 2) - (b.top + b.height / 2));
|
||||||
|
}).filter(x => x !== null));
|
||||||
|
expect(offsets.length).toBeGreaterThan(1);
|
||||||
|
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
||||||
|
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
|
||||||
|
// A browser asks for /favicon.ico on its own, signed in or not.
|
||||||
|
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
|
||||||
|
const r = await page.request.get(path, { headers: { cookie: '' } });
|
||||||
|
expect(r.status(), path).toBe(200);
|
||||||
|
expect(r.headers()['content-type'], path).toBe('image/png');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => {
|
||||||
|
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
|
||||||
|
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
|
||||||
|
await page.locator('.feed.group .chev').first().click();
|
||||||
|
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
||||||
|
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out';
|
||||||
|
renderFeeds();
|
||||||
|
});
|
||||||
|
await expect(page.locator('.ferr')).toHaveCount(2);
|
||||||
|
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
|
||||||
|
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
|
||||||
|
const xs = await page.$$eval('.chev, .ferr', els =>
|
||||||
|
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); }));
|
||||||
|
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
|
||||||
|
await page.reload(); // put the real list back
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('touch gestures on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
||||||
|
async function drag(page, from, to) {
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
const steps = 8;
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
||||||
|
for (let i = 1; i <= steps; i++)
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
|
||||||
|
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
const titles = await page.locator('.ep .t').allTextContents();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const shown = page.locator('#detail .dt');
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right: the one before
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 200, y: 300 }, { x: 210, y: 600 }); // down: a scroll, not a swipe
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
const box = await page.locator('#list').boundingBox();
|
||||||
|
const fetch = page.waitForRequest(r => r.url().endsWith('/api/fetch') && r.method() === 'POST');
|
||||||
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
||||||
|
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
|
||||||
|
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('an item with no files, on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
test('shows no files box at all', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
// An item with no enclosure, found from the list the page itself has.
|
||||||
|
const guid = await page.evaluate(() => S.entries.find(e => !e.enclosures.length)?.guid);
|
||||||
|
expect(guid, 'the fixtures have an item with no files').toBeTruthy();
|
||||||
|
await page.locator(`.ep[data-guid="${guid}"]`).click();
|
||||||
|
await expect(page.locator('#detail .dt')).toBeVisible();
|
||||||
|
await expect(page.locator('#detail .encbox')).toHaveCount(0);
|
||||||
|
await expect(page.locator('#detail')).not.toContainText('No files');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
||||||
|
const rows = page.locator('#feedlist .feed');
|
||||||
|
const group = page.locator('.feed.group').first();
|
||||||
|
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
||||||
|
const child = page.locator('.feed.child').first();
|
||||||
|
const name = (await child.locator('.txt b').textContent()).trim();
|
||||||
|
await child.click();
|
||||||
|
await page.locator('#content .acts [data-a="pin"]').click();
|
||||||
|
|
||||||
|
// First in the list, out of its folder, marked, and with the rule under it.
|
||||||
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
||||||
|
await expect(rows.first()).toHaveClass(/\bpinned\b/);
|
||||||
|
await expect(rows.first()).not.toHaveClass(/\bchild\b/);
|
||||||
|
await expect(rows.first()).toHaveClass(/\blastpin\b/);
|
||||||
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0);
|
||||||
|
await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
// It is on the account: a reload keeps it.
|
||||||
|
await page.reload();
|
||||||
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
||||||
|
|
||||||
|
// Unpinned, it goes back into its folder.
|
||||||
|
await rows.first().click();
|
||||||
|
await page.locator('#content .acts [data-a="pin"]').click();
|
||||||
|
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
<channel><title>Test Show</title><link>http://127.0.0.1:8792/</link><description>A fixture feed.</description>
|
<channel><title>Test Show</title><link>http://127.0.0.1:8792/</link><description>A fixture feed.</description>
|
||||||
<itunes:image href="http://127.0.0.1:8792/art.png"/>
|
<itunes:image href="http://127.0.0.1:8792/art.png"/>
|
||||||
|
<itunes:category text="Technology"/>
|
||||||
<item><title>First Episode</title><guid>ui-1</guid>
|
<item><title>First Episode</title><guid>ui-1</guid>
|
||||||
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||||
<description><p>Show notes for the first one.</p></description>
|
<description><p>Show notes for the first one.</p></description>
|
||||||
|
|||||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
// Type-checks web/src (npx tsc). Nothing is emitted: web/build.mjs does that with swc.
|
||||||
|
// The files are one script in one scope, not modules, which is why there are no imports.
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"lib": ["es2022", "dom", "dom.iterable"],
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": false,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["web/src/*.ts"]
|
||||||
|
}
|
||||||
92
web/Inter-LICENSE.txt
Normal file
92
web/Inter-LICENSE.txt
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
web/InterVariable.woff2
Normal file
BIN
web/InterVariable.woff2
Normal file
Binary file not shown.
34
web/admin.html
Normal file
34
web/admin.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<title>iPodderX admin</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<link rel="stylesheet" data-src="app.css">
|
||||||
|
</head>
|
||||||
|
<body class="adminpage">
|
||||||
|
<!-- The server sends this page, and its script, to admins only. -->
|
||||||
|
<header id="topbar">
|
||||||
|
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
|
||||||
|
<img class="logo" src="/icon.png" alt="" width="26">
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<nav class="tabs" id="atabs">
|
||||||
|
<a href="#server" data-t="server">Server</a>
|
||||||
|
<a href="#accounts" data-t="accounts">Accounts</a>
|
||||||
|
<a href="#log" data-t="log">Log</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="wrap plain" id="admin">
|
||||||
|
<section id="server" hidden></section>
|
||||||
|
<section id="accounts" hidden></section>
|
||||||
|
<section id="log" hidden></section>
|
||||||
|
</main>
|
||||||
|
<div id="toasts"></div>
|
||||||
|
|
||||||
|
<script data-src="web/src"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
847
web/app.css
Normal file
847
web/app.css
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
/* Inter, served by ipx itself (/inter.woff2) rather than a font CDN, so the page asks nothing of
|
||||||
|
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
|
||||||
|
keeps Lucida Grande, the 2004 app's face. */
|
||||||
|
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
|
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
|
||||||
|
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
|
||||||
|
:root {
|
||||||
|
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
||||||
|
--panel:#151c27;
|
||||||
|
--panel2:#1c2431;
|
||||||
|
--raise:#25303f;
|
||||||
|
--line:#2c3849;
|
||||||
|
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||||
|
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||||
|
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||||
|
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||||
|
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||||
|
--ink:#0e131b; /* text on an accent fill */
|
||||||
|
--good:#6fbf8b;
|
||||||
|
--warn:#f49e2c; /* the amber doubles as the pending colour */
|
||||||
|
--bad:#e2705f;
|
||||||
|
--shadow:0 8px 28px rgba(6,10,16,.55);
|
||||||
|
}
|
||||||
|
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
|
||||||
|
script sets data-mode to light or dark, working Auto out from the system, so each theme has
|
||||||
|
one block per variant here and none needs repeating under a media query. */
|
||||||
|
:root[data-theme="modern"][data-mode="light"] {
|
||||||
|
--bg:#f2f4f7;
|
||||||
|
--panel:#ffffff; /* #FFFFFF device body */
|
||||||
|
--panel2:#e9edf3;
|
||||||
|
--raise:#dde3ec;
|
||||||
|
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||||
|
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||||
|
--dim:#606060; /* #606060 */
|
||||||
|
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
||||||
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
||||||
|
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#2f7d4f;
|
||||||
|
--warn:#b06f10;
|
||||||
|
--bad:#b3402f;
|
||||||
|
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||||
|
}
|
||||||
|
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
|
||||||
|
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
|
||||||
|
:root[data-theme="dracula"] {
|
||||||
|
--bg:#282a36;
|
||||||
|
--panel:#21222c;
|
||||||
|
--panel2:#343746;
|
||||||
|
--raise:#44475a; /* selection */
|
||||||
|
--line:#414558;
|
||||||
|
--fg:#f8f8f2;
|
||||||
|
--dim:#c9cbd6;
|
||||||
|
--faint:#9ea8c7;
|
||||||
|
--accent:#bd93f9; /* purple */
|
||||||
|
--accent2:#ff79c6; /* pink */
|
||||||
|
--ink:#282a36;
|
||||||
|
--good:#50fa7b;
|
||||||
|
--warn:#ffb86c;
|
||||||
|
--bad:#ff8080;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="dracula"][data-mode="light"] {
|
||||||
|
--bg:#fffbeb;
|
||||||
|
--panel:#f7f2df;
|
||||||
|
--panel2:#efe9d3;
|
||||||
|
--raise:#cfcfde; /* selection */
|
||||||
|
--line:#ddd6bd;
|
||||||
|
--fg:#1f1f1f;
|
||||||
|
--dim:#454137;
|
||||||
|
--faint:#635d44;
|
||||||
|
--accent:#644ac9;
|
||||||
|
--accent2:#a3144d;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#14710a;
|
||||||
|
--warn:#a34d14;
|
||||||
|
--bad:#b83322;
|
||||||
|
--shadow:0 8px 28px rgba(108,102,75,.18);
|
||||||
|
}
|
||||||
|
/* Material 3's baseline scheme: surface, the surface containers, outline and primary. */
|
||||||
|
:root[data-theme="material"] {
|
||||||
|
--bg:#141218; /* surface */
|
||||||
|
--panel:#1d1b20; /* surface-container-low */
|
||||||
|
--panel2:#211f26; /* surface-container */
|
||||||
|
--raise:#36343b; /* surface-container-highest */
|
||||||
|
--line:#49454f; /* outline-variant */
|
||||||
|
--fg:#e6e0e9; /* on-surface */
|
||||||
|
--dim:#cac4d0; /* on-surface-variant */
|
||||||
|
--faint:#9a95a0; /* outline, a shade up to clear AA on the containers */
|
||||||
|
--accent:#d0bcff; /* primary */
|
||||||
|
--accent2:#efb8c8; /* tertiary */
|
||||||
|
--ink:#381e72; /* on-primary */
|
||||||
|
--good:#8fd6a0;
|
||||||
|
--warn:#f2c46b;
|
||||||
|
--bad:#f2b8b5; /* error */
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
:root[data-theme="material"][data-mode="light"] {
|
||||||
|
--bg:#fef7ff;
|
||||||
|
--panel:#f7f2fa;
|
||||||
|
--panel2:#f3edf7;
|
||||||
|
--raise:#e6e0e9;
|
||||||
|
--line:#cac4d0;
|
||||||
|
--fg:#1d1b20;
|
||||||
|
--dim:#49454f;
|
||||||
|
--faint:#67626c; /* outline (#79747E), a shade down to clear AA on the containers */
|
||||||
|
--accent:#6750a4;
|
||||||
|
--accent2:#7d5260;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#2b6c3c;
|
||||||
|
--warn:#7c5800;
|
||||||
|
--bad:#b3261e;
|
||||||
|
--shadow:0 8px 28px rgba(103,80,164,.14);
|
||||||
|
}
|
||||||
|
/* Adwaita, from libadwaita's own CSS variables: view, window and sidebar backgrounds, the blue
|
||||||
|
accent, and its destructive, success and warning colours. */
|
||||||
|
:root[data-theme="adwaita"] {
|
||||||
|
--bg:#1d1d20; /* view */
|
||||||
|
--panel:#2e2e32; /* sidebar, headerbar */
|
||||||
|
--panel2:#222226; /* window */
|
||||||
|
--raise:#3a3a3f;
|
||||||
|
--line:#3f3f45;
|
||||||
|
--fg:#ffffff;
|
||||||
|
--dim:#c4c4c8;
|
||||||
|
--faint:#a0a0a7;
|
||||||
|
--accent:#81d0ff;
|
||||||
|
--accent2:#3584e4;
|
||||||
|
--ink:#1d1d20;
|
||||||
|
--good:#78e9ab;
|
||||||
|
--warn:#ffc252;
|
||||||
|
--bad:#ff938c;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
:root[data-theme="adwaita"][data-mode="light"] {
|
||||||
|
--bg:#ffffff;
|
||||||
|
--panel:#ebebed;
|
||||||
|
--panel2:#fafafb;
|
||||||
|
--raise:#dcdce0;
|
||||||
|
--line:#d9d9dd;
|
||||||
|
--fg:#333338; /* rgb(0 0 6 / 80%) on white */
|
||||||
|
--dim:#57575d;
|
||||||
|
--faint:#66666c;
|
||||||
|
--accent:#0461be;
|
||||||
|
--accent2:#3584e4;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#00753a;
|
||||||
|
--warn:#905400;
|
||||||
|
--bad:#c30000;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,6,.12);
|
||||||
|
}
|
||||||
|
/* Flat Remix, from its GTK theme's _colors.scss: base and bg, fg, the selection blue, and its
|
||||||
|
link colours, which are the selection blue taken down (light) or up (dark) until readable. */
|
||||||
|
:root[data-theme="flatremix"] {
|
||||||
|
--bg:#272a34; /* base */
|
||||||
|
--panel:#23262f; /* bg: base darkened 2% */
|
||||||
|
--panel2:#2e323d;
|
||||||
|
--raise:#363b48;
|
||||||
|
--line:#3a3f4c;
|
||||||
|
--fg:#eeeeec;
|
||||||
|
--dim:#babec8;
|
||||||
|
--faint:#959baa;
|
||||||
|
--accent:#8fb6ff; /* link */
|
||||||
|
--accent2:#fd7d00; /* warning orange */
|
||||||
|
--ink:#1a1c23;
|
||||||
|
--good:#4cc28c;
|
||||||
|
--warn:#ff9a3c;
|
||||||
|
--bad:#ff6b6b;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
:root[data-theme="flatremix"][data-mode="light"] {
|
||||||
|
--bg:#fafafa; /* base */
|
||||||
|
--panel:#ffffff; /* bg */
|
||||||
|
--panel2:#f0f0f1;
|
||||||
|
--raise:#e4e5e7;
|
||||||
|
--line:#d9d9d9; /* borders: bg darkened 15% */
|
||||||
|
--fg:#22252b;
|
||||||
|
--dim:#5c616c; /* fg */
|
||||||
|
--faint:#686d78;
|
||||||
|
--accent:#0060f0; /* link */
|
||||||
|
--accent2:#fd7d00;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#23794f;
|
||||||
|
--warn:#a85400;
|
||||||
|
--bad:#d41919;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
/* Paper: a single light palette, black on the colour of paper, colour kept for what matters.
|
||||||
|
Its blue and red are taken down a shade to clear AA on that background. */
|
||||||
|
:root[data-theme="paper"] {
|
||||||
|
--bg:#f2eede;
|
||||||
|
--panel:#ebe7d7;
|
||||||
|
--panel2:#e4e0d0;
|
||||||
|
--raise:#d8d5c7; /* highlight */
|
||||||
|
--line:#cdc9b9;
|
||||||
|
--fg:#000000;
|
||||||
|
--dim:#3d3a33;
|
||||||
|
--faint:#5a564c;
|
||||||
|
--accent:#1a5fae;
|
||||||
|
--accent2:#b58900;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#216609;
|
||||||
|
--warn:#795a00;
|
||||||
|
--bad:#b1331f;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
|
||||||
|
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
|
||||||
|
:root[data-theme="nordic"] {
|
||||||
|
--bg:#2e3440; /* nord0 */
|
||||||
|
--panel:#3b4252; /* nord1 */
|
||||||
|
--panel2:#434c5e; /* nord2 */
|
||||||
|
--raise:#4c566a; /* nord3 */
|
||||||
|
--line:#434c5e;
|
||||||
|
--fg:#eceff4; /* nord6 */
|
||||||
|
--dim:#d8dee9; /* nord4 */
|
||||||
|
--faint:#b4bccb;
|
||||||
|
--accent:#93c9d8; /* nord8, a shade up */
|
||||||
|
--accent2:#ebcb8b; /* nord13 */
|
||||||
|
--ink:#2e3440;
|
||||||
|
--good:#b0c99b; /* nord14, a shade up */
|
||||||
|
--warn:#ebcb8b;
|
||||||
|
--bad:#f2aeb4;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
:root[data-theme="nordic"][data-mode="light"] {
|
||||||
|
--bg:#eceff4; /* nord6 */
|
||||||
|
--panel:#e5e9f0; /* nord5 */
|
||||||
|
--panel2:#d8dee9; /* nord4 */
|
||||||
|
--raise:#cdd4e0;
|
||||||
|
--line:#c5cedb;
|
||||||
|
--fg:#2e3440; /* nord0 */
|
||||||
|
--dim:#3b4252; /* nord1 */
|
||||||
|
--faint:#4c566a; /* nord3 */
|
||||||
|
--accent:#3a5e8a;
|
||||||
|
--accent2:#b0674e;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#446632;
|
||||||
|
--warn:#7e590e;
|
||||||
|
--bad:#9c3f49;
|
||||||
|
--shadow:0 8px 28px rgba(46,52,64,.15);
|
||||||
|
}
|
||||||
|
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||||
|
:root[data-theme="classic"] {
|
||||||
|
color-scheme:light;
|
||||||
|
--bg:#ffffff;
|
||||||
|
--panel:#e7ebf1; /* the source list's pale blue-grey */
|
||||||
|
--panel2:#f2f2f2;
|
||||||
|
--raise:#d5dce7;
|
||||||
|
--line:#a9a9a9;
|
||||||
|
--fg:#000000;
|
||||||
|
--dim:#444444;
|
||||||
|
--faint:#666666;
|
||||||
|
--accent:#3875d7; /* Aqua selection blue */
|
||||||
|
--accent2:#2a5db0;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#237a23;
|
||||||
|
--warn:#a15f00;
|
||||||
|
--bad:#c42b1c;
|
||||||
|
--shadow:0 4px 16px rgba(0,0,0,.28);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||||
|
[hidden]{display:none!important}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{
|
||||||
|
margin:0;background:var(--bg);color:var(--fg);
|
||||||
|
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||||
|
display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
|
||||||
|
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
|
||||||
|
measured against whichever state happened to be current -- sized too tall while the bar
|
||||||
|
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
|
||||||
|
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
|
||||||
|
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
|
||||||
|
the fallback for a browser that does not know dvh. */
|
||||||
|
height:100vh;height:100dvh;
|
||||||
|
}
|
||||||
|
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||||
|
a{color:var(--accent)}
|
||||||
|
/* Inset wherever a parent clips its overflow, or the toolbar and the feed list cut the ring off. */
|
||||||
|
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
.tgroup button:focus-visible,.tgroup a:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
|
||||||
|
@media (prefers-reduced-motion:reduce){
|
||||||
|
*,*::before,*::after{animation:none!important;transition:none!important}
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar{width:10px;height:10px}
|
||||||
|
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
|
||||||
|
::-webkit-scrollbar-track{background:transparent}
|
||||||
|
|
||||||
|
/* ---------- shell ---------- */
|
||||||
|
#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden}
|
||||||
|
/* [ hides the feed list. A phone slides it over the page instead, so this is for wider screens. */
|
||||||
|
@media (min-width:821px){
|
||||||
|
body.nosb #shell{grid-template-columns:minmax(0,1fr)}
|
||||||
|
body.nosb #sidebar{display:none}
|
||||||
|
}
|
||||||
|
#sidebar{
|
||||||
|
background:var(--panel);border-right:1px solid var(--line);
|
||||||
|
display:flex;flex-direction:column;min-height:0;
|
||||||
|
}
|
||||||
|
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
|
||||||
|
.brand .logo{
|
||||||
|
width:30px;height:30px;flex:none;object-fit:contain;
|
||||||
|
}
|
||||||
|
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
|
||||||
|
.iconbtn{
|
||||||
|
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
|
||||||
|
color:var(--dim);flex:none;
|
||||||
|
}
|
||||||
|
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
||||||
|
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
||||||
|
#topbar{
|
||||||
|
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
|
||||||
|
background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
|
}
|
||||||
|
#topbar .grow{flex:1}
|
||||||
|
#topbar #epSearch{width:260px;flex:none}
|
||||||
|
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
|
||||||
|
.tgroup button,.tgroup a{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
|
||||||
|
.tgroup a{display:inline-grid;place-items:center}
|
||||||
|
.tgroup button+button,.tgroup button+a{border-left:1px solid var(--line)}
|
||||||
|
.tgroup button:hover:not(:disabled),.tgroup a:hover{background:var(--raise);color:var(--fg)}
|
||||||
|
.tgroup button:disabled{opacity:.35;cursor:default}
|
||||||
|
.sidetools button,.sidefoot button{
|
||||||
|
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||||
|
padding:6px 8px;font-size:12.5px;color:var(--dim);
|
||||||
|
}
|
||||||
|
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
|
||||||
|
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
|
||||||
|
.sidefoot{
|
||||||
|
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
|
||||||
|
border-top:1px solid var(--line);flex:none;
|
||||||
|
}
|
||||||
|
.sidefoot .row{display:flex;gap:6px}
|
||||||
|
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
|
||||||
|
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
|
||||||
|
.who button:hover{color:var(--fg)}
|
||||||
|
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||||
|
.sidefoot i{font-style:normal;opacity:.75}
|
||||||
|
.searchwrap{padding:0 12px 8px}
|
||||||
|
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
||||||
|
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
|
||||||
|
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
||||||
|
}
|
||||||
|
input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||||
|
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
|
||||||
|
feeds still line up with the places above. */
|
||||||
|
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
|
||||||
|
.feed{
|
||||||
|
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
|
||||||
|
cursor:pointer;margin-bottom:1px;position:relative;
|
||||||
|
}
|
||||||
|
.feed:hover{background:var(--panel2)}
|
||||||
|
.feed.sel{background:var(--raise)}
|
||||||
|
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
||||||
|
.feed.child{margin-left:11px}
|
||||||
|
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
|
||||||
|
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)}
|
||||||
|
.feed.lastpin{margin-bottom:9px}
|
||||||
|
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
|
||||||
|
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||||||
|
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
||||||
|
above it. The button is the row's full height and 24 px wide: a near miss used to open the
|
||||||
|
folder's page, and 16 px left the triangle cramped against the art. */
|
||||||
|
.chev{
|
||||||
|
position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
|
||||||
|
border-radius:4px;color:var(--faint);
|
||||||
|
}
|
||||||
|
.chev:hover{color:var(--fg)}
|
||||||
|
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
||||||
|
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
||||||
|
a child's included, which is why it moves left by the child's indent. */
|
||||||
|
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
||||||
|
.ferr .i{width:12px;height:12px}
|
||||||
|
.feed.child .ferr{left:-27px}
|
||||||
|
.chev .i{width:12px;height:12px;transition:transform .12s}
|
||||||
|
.chev[aria-expanded="true"] .i{transform:rotate(90deg)}
|
||||||
|
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
|
||||||
|
.childrow{
|
||||||
|
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
|
||||||
|
border-radius:9px;cursor:pointer;background:var(--panel);
|
||||||
|
}
|
||||||
|
.childrow:hover{border-color:var(--accent)}
|
||||||
|
.childrow .art{width:32px;height:32px;font-size:12px}
|
||||||
|
.childrow .txt{flex:1;min-width:0}
|
||||||
|
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
/* Currently Listening: how far into each episode you are, as a rail along the foot of its row.
|
||||||
|
The one in the player is marked as it is everywhere else, by the amber EQ bars, and its rail
|
||||||
|
and time left move as it plays. The rest stay neutral, so that one is what stands out. */
|
||||||
|
#listening .childrow{position:relative;overflow:hidden;gap:12px;padding:10px 10px 13px}
|
||||||
|
#listening .childrow .art{width:44px;height:44px;font-size:13px}
|
||||||
|
#listening .txt b{display:flex;align-items:center;gap:7px}
|
||||||
|
#listening .txt b span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
#listening .txt small{display:flex;gap:12px;margin-top:3px;color:var(--faint);font-size:11.5px}
|
||||||
|
#listening .txt small .fd{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
#listening .txt small .left{flex:none;color:var(--dim)}
|
||||||
|
#listening .childrow:not(.now) .eq{display:none}
|
||||||
|
#listening .rail{position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--raise)}
|
||||||
|
#listening .rail i{display:block;height:100%;width:0;background:var(--faint);transition:width .25s linear}
|
||||||
|
#listening .now{border-color:color-mix(in srgb,var(--accent2) 60%,var(--line))}
|
||||||
|
#listening .now .eq,#listening .now .left{color:var(--accent2)}
|
||||||
|
#listening .now .rail i{background:var(--accent2)}
|
||||||
|
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
|
||||||
|
#listening [data-a=play]{color:var(--accent)}
|
||||||
|
#listening [data-a=remove]{color:var(--faint)}
|
||||||
|
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
|
||||||
|
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
|
||||||
|
are; a .badge's fill already means unread in the sidebar. */
|
||||||
|
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
|
||||||
|
.dirbar .tabs{flex:none}
|
||||||
|
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
|
||||||
|
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
|
||||||
|
.chips button:hover{color:var(--fg)}
|
||||||
|
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
|
||||||
|
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
|
||||||
|
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
|
||||||
|
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
|
||||||
|
.tiles>.hint{grid-column:1/-1}
|
||||||
|
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
|
||||||
|
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
|
||||||
|
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
||||||
|
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
||||||
|
.tile:hover .txt b{color:var(--accent)}
|
||||||
|
.tag{
|
||||||
|
font-size:11px;font-weight:600;
|
||||||
|
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
||||||
|
}
|
||||||
|
.art{
|
||||||
|
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||||
|
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
|
||||||
|
}
|
||||||
|
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
|
||||||
|
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
|
||||||
|
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
|
||||||
|
/* A folder: its first four shows' art, as iTunes built a playlist's from its albums. */
|
||||||
|
.art.mosaic{grid-template:1fr 1fr/1fr 1fr;place-items:stretch}
|
||||||
|
.art.mosaic img{width:100%;height:100%;min-height:0;object-fit:cover;display:block}
|
||||||
|
.feed .art{width:34px;height:34px;font-size:13px}
|
||||||
|
.feed .txt{min-width:0;flex:1}
|
||||||
|
.feed .txt b{display:block;font-weight:500;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.badge{
|
||||||
|
background:var(--accent2);color:var(--ink);border-radius:20px;padding:1px 7px;
|
||||||
|
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
||||||
|
}
|
||||||
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||||
|
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||||||
|
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* ---------- main ---------- */
|
||||||
|
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
|
||||||
|
.wrap{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column}
|
||||||
|
.wrap.plain{overflow-y:auto;padding:18px 22px 40px;display:block}
|
||||||
|
#content{display:flex;flex-direction:column;min-height:0;min-width:0;flex:1}
|
||||||
|
#content>.fhead,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none}
|
||||||
|
#content>.fhead{padding-top:16px}
|
||||||
|
|
||||||
|
/* Three panes, as the original had: feeds beside, items above, the item below. */
|
||||||
|
#split{
|
||||||
|
display:grid;flex:1;min-height:0;
|
||||||
|
grid-template-columns:minmax(0,1fr) 270px;
|
||||||
|
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
|
||||||
|
grid-template-areas:"list files" "grab grab" "detail detail";
|
||||||
|
}
|
||||||
|
#list{grid-area:list;overflow:auto;padding:0 14px 10px;overscroll-behavior-y:contain}
|
||||||
|
/* Pull-to-refresh's note (gestures.ts). overscroll-behavior keeps the browser's own pull, which
|
||||||
|
reloads the page, from starting on top of it. */
|
||||||
|
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
|
||||||
|
font-size:12px;color:var(--faint)}
|
||||||
|
/* The selected item's files, beside the list, as the original's Files pane was. */
|
||||||
|
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
|
||||||
|
/* Nothing selected, or an item with no files: the list takes the width. */
|
||||||
|
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
|
||||||
|
#files .encbox{margin-top:8px}
|
||||||
|
#files .empty{padding:24px 0}
|
||||||
|
.fhd{font-size:12px;color:var(--faint)}
|
||||||
|
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
|
||||||
|
#grab:hover{background:var(--accent)}
|
||||||
|
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
|
||||||
|
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
|
||||||
|
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
||||||
|
.dmeta .btn{padding:3px 9px;font-size:12px}
|
||||||
|
#detail .encbox{margin:0 0 10px}
|
||||||
|
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
||||||
|
.dbody img{max-width:100%;height:auto;border-radius:6px}
|
||||||
|
.dbody a{color:var(--accent)}
|
||||||
|
.dbody pre{overflow-x:auto;background:var(--panel2);padding:10px;border-radius:8px}
|
||||||
|
.encbox{
|
||||||
|
display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;
|
||||||
|
padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);
|
||||||
|
}
|
||||||
|
.fhead{display:flex;gap:18px;margin-bottom:18px}
|
||||||
|
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
|
||||||
|
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
|
||||||
|
.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2;overflow-wrap:anywhere}
|
||||||
|
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
|
||||||
|
.fhead .sub a{color:var(--dim);text-decoration:none}
|
||||||
|
.fhead .sub a:hover{color:var(--accent)}
|
||||||
|
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
|
||||||
|
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
|
||||||
|
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
||||||
|
.acts .btn.primary i{opacity:.9}
|
||||||
|
.acts .btn:hover{background:var(--raise)}
|
||||||
|
.acts .btn.primary:hover{background:var(--accent)}
|
||||||
|
.btn{
|
||||||
|
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
|
||||||
|
padding:6px 12px;font-size:13px;
|
||||||
|
}
|
||||||
|
.btn:hover{border-color:var(--accent)}
|
||||||
|
a.btn{text-decoration:none;color:inherit}
|
||||||
|
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
|
||||||
|
.btn.primary:hover{filter:brightness(1.08)}
|
||||||
|
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
|
||||||
|
/* An icon in place of a word; the word is in its tooltip. */
|
||||||
|
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
|
||||||
|
/* Inline in a sentence, next to a plain-word error explanation. */
|
||||||
|
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
|
||||||
|
/* The keys ? lists. */
|
||||||
|
.keys{border-collapse:collapse;width:100%;font-size:13px}
|
||||||
|
.keys th{text-align:left;font-weight:600;padding:12px 0 4px;color:var(--fg)}
|
||||||
|
.keys td{padding:3px 0;color:var(--dim)}
|
||||||
|
.keys td:first-child{width:10em;white-space:nowrap}
|
||||||
|
kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px}
|
||||||
|
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
|
||||||
|
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
|
||||||
|
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
|
||||||
|
it is here, red when the download failed. */
|
||||||
|
.kind{display:inline-grid;place-items:center;color:var(--dim)}
|
||||||
|
.kind.here{color:var(--good)}
|
||||||
|
.subbed{display:inline-flex;padding:0 9px;color:var(--good)}
|
||||||
|
.subbed .i{width:18px;height:18px}
|
||||||
|
.kind.bad{color:var(--bad)}
|
||||||
|
.fhead .art .i{width:22px;height:22px}
|
||||||
|
.toolbar{
|
||||||
|
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
|
||||||
|
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
|
||||||
|
}
|
||||||
|
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
|
||||||
|
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
|
||||||
|
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
|
||||||
|
.tabs a{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim);text-decoration:none}
|
||||||
|
.tabs a.on{background:var(--raise);color:var(--fg);font-weight:500}
|
||||||
|
|
||||||
|
/* ---------- the admin page (admin.html) ---------- */
|
||||||
|
/* One column that scrolls under the same header, rather than the app's fixed panes. */
|
||||||
|
body.adminpage{display:block;overflow:auto}
|
||||||
|
body.adminpage #topbar{position:sticky;top:0;z-index:5}
|
||||||
|
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
||||||
|
body.adminpage #topbar .logo{border-radius:5px}
|
||||||
|
body.adminpage #topbar > a.btn{text-decoration:none}
|
||||||
|
body.adminpage #admin{max-width:780px;margin:0 auto}
|
||||||
|
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
||||||
|
body.adminpage #log{max-width:none}
|
||||||
|
.urow{padding:9px 0;border-bottom:1px solid var(--line)}
|
||||||
|
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
||||||
|
|
||||||
|
/* ---------- items ---------- */
|
||||||
|
/* A table, as the original's was: unread, kept, title, feed, file, size, published. */
|
||||||
|
.ephead,.ep{
|
||||||
|
display:grid;gap:8px;align-items:center;
|
||||||
|
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) 56px minmax(0,72px) minmax(0,92px) 64px;
|
||||||
|
}
|
||||||
|
/* A pixel more side padding than a row's: a row has a 1px border the heading has not, and without
|
||||||
|
it every heading sat a pixel left of its column. */
|
||||||
|
.ephead{
|
||||||
|
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 11px 5px;
|
||||||
|
border-bottom:1px solid var(--line);margin-bottom:3px;
|
||||||
|
font-size:12px;color:var(--faint);
|
||||||
|
}
|
||||||
|
/* Each heading sorts by its column; the caret says which way. */
|
||||||
|
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
|
||||||
|
font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}
|
||||||
|
.ephead .hs:hover,.ephead .hs.on{color:var(--fg)}
|
||||||
|
.ephead .hs .arr{display:inline-flex}
|
||||||
|
.ephead .hs .arr .i{width:8px;height:8px}
|
||||||
|
.ephead .hs .arr.asc{transform:rotate(-90deg)}
|
||||||
|
.ephead .hs .arr.desc{transform:rotate(90deg)}
|
||||||
|
/* An icon heading is centred over its column, as the icons under it are, and its caret hangs
|
||||||
|
outside so sorting by it does not push the icon off line. */
|
||||||
|
.ephead .hs.h-ic{justify-content:center;position:relative;width:100%}
|
||||||
|
.ephead .hs.h-ic .arr{position:absolute;left:100%}
|
||||||
|
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
|
||||||
|
.ep>*{min-width:0}
|
||||||
|
.ep.sel{background:var(--raise);border-color:var(--line)}
|
||||||
|
.ep:hover{background:var(--panel)}
|
||||||
|
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
|
||||||
|
primary action and links, so a count no longer looks like a button. */
|
||||||
|
/* No padding: a button keeps the browser's side padding, which left too little room for the 16px
|
||||||
|
icon, so it spilled 3px right of centre and off line with its heading. */
|
||||||
|
.ep .st,.ep .fl{width:22px;height:22px;padding:0;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
|
||||||
|
.ep .fl{color:var(--faint);font-size:13px}
|
||||||
|
.ep .fl.on{color:var(--fg)}
|
||||||
|
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
|
||||||
|
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
|
||||||
|
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
|
||||||
|
.eq i{flex:1;height:100%;background:currentColor;border-radius:1px;transform-origin:bottom}
|
||||||
|
.eq i:nth-child(1){height:60%}
|
||||||
|
.eq i:nth-child(3){height:40%}
|
||||||
|
body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
|
||||||
|
body.playing .eq i:nth-child(1){animation-delay:-.3s}
|
||||||
|
body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||||
|
@keyframes eq{from{transform:scaleY(.35)}}
|
||||||
|
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
|
||||||
|
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.ep.read .t{color:var(--dim);font-weight:500}
|
||||||
|
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
|
||||||
|
.ep .line:empty{display:none}
|
||||||
|
.ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.ep .file{display:flex;gap:6px;align-items:center;position:relative;color:var(--faint);font-size:11.5px}
|
||||||
|
/* Under the icon, not beside it or on a line of its own: taking a line, it lifted the icon of
|
||||||
|
every file not yet downloaded above the downloaded ones' (issue #31). */
|
||||||
|
.ep .file .dlbar{position:absolute;left:0;right:0;bottom:-5px;margin:0}
|
||||||
|
.ep .rowacts{justify-content:flex-end}
|
||||||
|
/* One feed's own table has no need of a Feed column; All Subscriptions does. */
|
||||||
|
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px}
|
||||||
|
#split.one .h-fd,#split.one .ep .fd{display:none}
|
||||||
|
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
|
||||||
|
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
|
||||||
|
.ep:hover .rowacts{opacity:1}
|
||||||
|
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
|
||||||
|
.dlbar.live{background:var(--raise)}
|
||||||
|
.dlbar i{display:block;height:100%;width:0;background:var(--accent2);transition:width .25s}
|
||||||
|
.empty{color:var(--faint);text-align:center;padding:50px 0}
|
||||||
|
#more{display:block;width:100%;margin-top:10px}
|
||||||
|
|
||||||
|
/* ---------- player ---------- */
|
||||||
|
#player{
|
||||||
|
border-top:1px solid var(--line);background:var(--panel);
|
||||||
|
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
|
||||||
|
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||||
|
}
|
||||||
|
#player.on{display:grid}
|
||||||
|
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||||
|
file needs to be seen, floated above the bar rather than laid into it, so an audio episode's
|
||||||
|
layout is unchanged. */
|
||||||
|
#audio{display:none}
|
||||||
|
body.has-video #audio{
|
||||||
|
display:block;position:fixed;z-index:45;right:16px;bottom:120px;
|
||||||
|
width:360px;max-width:calc(100vw - 32px);aspect-ratio:16/9;background:#000;
|
||||||
|
border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
|
||||||
|
#pnow .art{width:44px;height:44px;font-size:13px}
|
||||||
|
#pnow .txt{min-width:0}
|
||||||
|
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
#pnow .eq{color:var(--accent2);margin-right:6px}
|
||||||
|
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
|
||||||
|
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||||
|
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
|
||||||
|
#pbtns .iconbtn{width:34px;height:34px}
|
||||||
|
#pplay{
|
||||||
|
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
|
||||||
|
border-radius:50%;
|
||||||
|
}
|
||||||
|
#pplay:hover{background:var(--accent);color:var(--ink)}
|
||||||
|
#seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
|
||||||
|
input[type=range]{
|
||||||
|
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
|
||||||
|
background:var(--raise);cursor:pointer;
|
||||||
|
}
|
||||||
|
input[type=range]::-webkit-slider-thumb{
|
||||||
|
-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);
|
||||||
|
}
|
||||||
|
input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--accent)}
|
||||||
|
#pright{display:flex;gap:8px;align-items:center}
|
||||||
|
#pright select{width:auto;padding:4px 6px;font-size:12px}
|
||||||
|
#vol{width:78px;flex:none}
|
||||||
|
|
||||||
|
/* ---------- overlays ---------- */
|
||||||
|
#modal{
|
||||||
|
position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;
|
||||||
|
place-items:center;z-index:50;padding:20px;
|
||||||
|
}
|
||||||
|
#modal.on{display:grid}
|
||||||
|
.card{
|
||||||
|
background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
||||||
|
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
|
||||||
|
}
|
||||||
|
.card.wide{width:min(1000px,100%)}
|
||||||
|
#logbox{
|
||||||
|
background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:10px 12px;
|
||||||
|
height:min(60vh,520px);overflow:auto;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||||
|
}
|
||||||
|
#logbox .l{display:flex;gap:9px;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||||
|
#logbox time{color:var(--faint);flex:none}
|
||||||
|
#logbox .lv{flex:none;width:42px;font-weight:700}
|
||||||
|
#logbox .lv.ERROR{color:var(--bad)} #logbox .lv.WARN{color:var(--warn)}
|
||||||
|
#logbox .lv.INFO{color:var(--accent)} #logbox .lv.DEBUG,#logbox .lv.TRACE{color:var(--faint)}
|
||||||
|
#logbox .tg{color:var(--faint);flex:none}
|
||||||
|
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
||||||
|
.logbar .grow{flex:1;min-width:120px}
|
||||||
|
.card h3{margin:0 0 14px;font-size:17px}
|
||||||
|
.field{display:grid;gap:4px;margin-bottom:12px}
|
||||||
|
.field label{font-size:12px;color:var(--dim)}
|
||||||
|
.field .hint{font-size:11.5px;color:var(--faint)}
|
||||||
|
.check{display:flex;gap:8px;align-items:center;font-size:13.5px;margin-bottom:9px}
|
||||||
|
.inline{display:flex;gap:6px;align-items:stretch}
|
||||||
|
.inline input{flex:1;min-width:0}
|
||||||
|
.inline .btn{white-space:nowrap;flex:none}
|
||||||
|
.inline select{flex:none;width:auto}
|
||||||
|
.inline input[type=number]{flex:none;width:90px}
|
||||||
|
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
||||||
|
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||||||
|
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
|
||||||
|
.toast{
|
||||||
|
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||||
|
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||||
|
}
|
||||||
|
.toast.bad{border-color:var(--bad);color:var(--bad)}
|
||||||
|
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||||
|
#burger,#dback{display:none}
|
||||||
|
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||||
|
#status{
|
||||||
|
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
||||||
|
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||||
|
}
|
||||||
|
/* The feed's own header, kept to one line so the table starts high, as it did. */
|
||||||
|
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
|
||||||
|
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
|
||||||
|
.fhead.slim .meta{flex:1 1 260px}
|
||||||
|
/* One line each, or a long title wraps to three and pushes the table down. */
|
||||||
|
.fhead.slim h2,.fhead.slim .sub.stat{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.fhead.slim h2{font-size:17px}
|
||||||
|
.fhead.slim .sub{margin-bottom:0;font-size:12.5px}
|
||||||
|
.fhead.slim .acts{margin-top:0;flex:none}
|
||||||
|
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
|
||||||
|
/* Places above the feeds: not subscriptions, but where to look. */
|
||||||
|
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
|
||||||
|
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
|
||||||
|
.place:hover{background:var(--panel2)}
|
||||||
|
.place.sel{background:var(--raise)}
|
||||||
|
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
|
||||||
|
.place b{flex:1;font-weight:500;font-size:13.5px}
|
||||||
|
|
||||||
|
@media (max-width:820px){
|
||||||
|
#shell{grid-template-columns:1fr}
|
||||||
|
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
||||||
|
#sidebar.open{transform:none}
|
||||||
|
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||||
|
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
|
||||||
|
|
||||||
|
/* The feed list is reachable whether or not anything is playing. */
|
||||||
|
#burger{display:grid}
|
||||||
|
#topbar{gap:6px;padding:6px 8px}
|
||||||
|
#topbar .grow,.tgroup.item{display:none}
|
||||||
|
#topbar #epSearch{width:auto;flex:1;min-width:0}
|
||||||
|
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
||||||
|
.fhead.slim .acts{flex:1 1 100%}
|
||||||
|
.iconbtn{width:38px;height:38px}
|
||||||
|
|
||||||
|
/* One pane at a time: the list, then the item over it. */
|
||||||
|
#split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"}
|
||||||
|
/* Title and date only; the files sit above the item's text in the reader instead. */
|
||||||
|
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
|
||||||
|
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
|
||||||
|
#grab{display:none}
|
||||||
|
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
|
||||||
|
body.reading #detail{display:block}
|
||||||
|
#dback{display:inline-block;margin-bottom:10px}
|
||||||
|
|
||||||
|
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
|
||||||
|
#content>.fhead{padding-top:12px}
|
||||||
|
.fhead{gap:12px;margin-bottom:12px}
|
||||||
|
.fhead .art{width:64px;height:64px;font-size:20px}
|
||||||
|
.fhead h2{font-size:18px}
|
||||||
|
.fhead .sub{font-size:12px;margin-bottom:6px}
|
||||||
|
.acts{gap:6px}
|
||||||
|
.acts .btn{padding:7px 10px;font-size:12.5px}
|
||||||
|
.toolbar{gap:8px}
|
||||||
|
#list{padding:0 12px 10px}
|
||||||
|
#player{grid-template-columns:1fr auto;grid-template-areas:"now right" "mid mid";gap:2px 8px}
|
||||||
|
#pnow{grid-area:now;width:auto}
|
||||||
|
#pright{grid-area:right}
|
||||||
|
#pmid{grid-area:mid}
|
||||||
|
#pnow .art{width:38px;height:38px}
|
||||||
|
#pnow .txt small,#pright #vol{display:none}
|
||||||
|
.wrap.plain{padding:14px}
|
||||||
|
.card{padding:16px}
|
||||||
|
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
|
||||||
|
the grid down. */
|
||||||
|
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
|
||||||
|
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
|
||||||
|
|
||||||
|
/* A log line has no room for four columns: keep time and message, wrap as prose. */
|
||||||
|
#logbox{font-size:11.5px;padding:8px}
|
||||||
|
#logbox .l{flex-wrap:wrap;gap:6px}
|
||||||
|
#logbox .lv{width:auto}
|
||||||
|
#logbox .tg{display:none}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Classic: the 2004 Mac app ---------- */
|
||||||
|
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
|
||||||
|
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
|
||||||
|
:root[data-theme="classic"] body{
|
||||||
|
font-family:"Lucida Grande","Lucida Sans Unicode","Lucida Sans",Geneva,Verdana,sans-serif;font-size:13px;
|
||||||
|
}
|
||||||
|
:root[data-theme="classic"] #topbar,
|
||||||
|
:root[data-theme="classic"] #status,
|
||||||
|
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
|
||||||
|
:root[data-theme="classic"] #status{text-align:center}
|
||||||
|
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
|
||||||
|
:root[data-theme="classic"] #files{background:#f4f4f4}
|
||||||
|
:root[data-theme="classic"] .tgroup{background:none;border-color:#8e8e8e;border-radius:6px}
|
||||||
|
:root[data-theme="classic"] .tgroup button+button,:root[data-theme="classic"] .tgroup button+a{border-left-color:#8e8e8e}
|
||||||
|
:root[data-theme="classic"] .tgroup button,
|
||||||
|
:root[data-theme="classic"] .tgroup a,
|
||||||
|
:root[data-theme="classic"] .btn,
|
||||||
|
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
|
||||||
|
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
|
||||||
|
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
|
||||||
|
:root[data-theme="classic"] .tgroup a:hover,
|
||||||
|
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
|
||||||
|
:root[data-theme="classic"] .btn.primary,
|
||||||
|
:root[data-theme="classic"] .tabs button.on,
|
||||||
|
:root[data-theme="classic"] .feed.sel,
|
||||||
|
:root[data-theme="classic"] .place.sel{background:linear-gradient(#86b0ec,#3a73cf);border-color:#2d5eae;color:#fff}
|
||||||
|
:root[data-theme="classic"] .feed.sel .txt small,
|
||||||
|
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
|
||||||
|
:root[data-theme="classic"] #epSearch{border-radius:14px}
|
||||||
|
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
|
||||||
|
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
||||||
|
:root[data-theme="classic"] .ephead,
|
||||||
|
:root[data-theme="classic"] .fhd{
|
||||||
|
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;font-size:11px;
|
||||||
|
}
|
||||||
|
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
|
||||||
|
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
|
||||||
|
:root[data-theme="classic"] .ep{border-radius:0;margin:0}
|
||||||
|
:root[data-theme="classic"] #eps .ep:nth-child(even){background:#edf3fe}
|
||||||
|
:root[data-theme="classic"] .ep .t{color:#1c4fa8}
|
||||||
|
:root[data-theme="classic"] .ep.read .t{color:#333}
|
||||||
|
:root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7}
|
||||||
|
:root[data-theme="classic"] .ep.sel .t,
|
||||||
|
:root[data-theme="classic"] .ep.sel .fd,
|
||||||
|
:root[data-theme="classic"] .ep.sel .size,
|
||||||
|
:root[data-theme="classic"] .ep.sel .date,
|
||||||
|
:root[data-theme="classic"] .ep.sel .line,
|
||||||
|
:root[data-theme="classic"] .ep.sel .st,
|
||||||
|
:root[data-theme="classic"] .ep.sel .fl,
|
||||||
|
:root[data-theme="classic"] .ep.sel .file,
|
||||||
|
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
|
||||||
|
/* Green and red stay green and red on the blue, just lighter so they read. */
|
||||||
|
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
||||||
|
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
||||||
|
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
|
||||||
|
:root[data-theme="classic"] .childrow{background:#fff}
|
||||||
|
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
||||||
BIN
web/apple-touch-icon.png
Normal file
BIN
web/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
84
web/build.mjs
Normal file
84
web/build.mjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
|
||||||
|
// by swc into a script of its own (app.js, login.js), and the page minified, its
|
||||||
|
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
|
||||||
|
// include_str!s the results, so the binary still carries everything and nothing is served
|
||||||
|
// from disk.
|
||||||
|
//
|
||||||
|
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
||||||
|
// server lets a browser keep that for a year without asking again. A changed script is a new
|
||||||
|
// URL, and the page, which the browser checks on every visit, is what carries it.
|
||||||
|
//
|
||||||
|
// node web/build.mjs [out-dir] default out-dir: web/dist
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import swc from '@swc/core';
|
||||||
|
import html from '@swc/html';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||||
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||||
|
const PAGES = {
|
||||||
|
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] },
|
||||||
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||||
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
|
};
|
||||||
|
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
|
||||||
|
const STYLE = 'app.css';
|
||||||
|
|
||||||
|
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
||||||
|
|
||||||
|
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
||||||
|
/// one; the doctype only keeps it from complaining that a fragment has none.
|
||||||
|
export function buildStyle({ minify = true } = {}) {
|
||||||
|
const css = fs.readFileSync(path.join(here, STYLE), 'utf8');
|
||||||
|
if (!minify) return css;
|
||||||
|
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
||||||
|
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
||||||
|
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
|
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||||
|
export function buildPage(name, { minify = true } = {}) {
|
||||||
|
const { script, src: files } = PAGES[name];
|
||||||
|
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n');
|
||||||
|
const js = swc.transformSync(src, {
|
||||||
|
filename: name + '.ts',
|
||||||
|
jsc: {
|
||||||
|
parser: { syntax: 'typescript' },
|
||||||
|
target: 'es2022',
|
||||||
|
// Top-level names stay as they are: markup calls some of them by name (onclick="closeModal()")
|
||||||
|
// and the browser tests reach others (player, savePos) through page.evaluate.
|
||||||
|
minify: minify ? { compress: { toplevel: false }, mangle: { toplevel: false } } : undefined,
|
||||||
|
},
|
||||||
|
isModule: false,
|
||||||
|
minify,
|
||||||
|
}).code;
|
||||||
|
const page = fs.readFileSync(path.join(here, name), 'utf8');
|
||||||
|
const marker = /<script data-src="[^"]*"><\/script>/;
|
||||||
|
if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`);
|
||||||
|
// Where the inline script was, and a plain <script src>, so it still runs in the same place:
|
||||||
|
// after the markup it wires up, before anything else.
|
||||||
|
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
||||||
|
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
||||||
|
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
||||||
|
if (!minify) return { html: out, js, script };
|
||||||
|
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||||
|
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
||||||
|
if (bad.length) throw new Error(`${name}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
|
return { html: r.code, js, script };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
const out = process.argv[2] || path.join(here, 'dist');
|
||||||
|
fs.mkdirSync(out, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
||||||
|
for (const name of Object.keys(PAGES)) {
|
||||||
|
const { html, js, script } = buildPage(name);
|
||||||
|
fs.writeFileSync(path.join(out, name), html);
|
||||||
|
fs.writeFileSync(path.join(out, script), js);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/favicon.png
Normal file
BIN
web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
2018
web/index.html
2018
web/index.html
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
<title>Sign in — iPodderX</title>
|
<title>Sign in — iPodderX</title>
|
||||||
<link rel="icon" href="/icon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<style>
|
<style>
|
||||||
|
/* Inter, from ipx itself; see the same rule in index.html. */
|
||||||
|
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
:root {
|
:root {
|
||||||
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
||||||
--panel:#151c27;
|
--panel:#151c27;
|
||||||
@@ -40,7 +43,7 @@
|
|||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{
|
body{
|
||||||
margin:0;display:grid;place-items:center;background:var(--bg);color:var(--fg);
|
margin:0;display:grid;place-items:center;background:var(--bg);color:var(--fg);
|
||||||
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:20px;
|
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:20px;
|
||||||
}
|
}
|
||||||
form{
|
form{
|
||||||
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
||||||
@@ -74,20 +77,4 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|||||||
<p class="msg" id="msg"></p>
|
<p class="msg" id="msg"></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script data-src="web/src"></script>
|
||||||
document.getElementById('f').onsubmit = async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const msg = document.getElementById('msg');
|
|
||||||
msg.textContent = '';
|
|
||||||
const r = await fetch('/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: document.getElementById('name').value,
|
|
||||||
password: document.getElementById('pw').value,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (r.ok) location.href = '/';
|
|
||||||
else msg.textContent = await r.text() || 'Sign in failed';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|||||||
204
web/src/admin.ts
Normal file
204
web/src/admin.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/* ---------------- admin page ---------------- */
|
||||||
|
// /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's
|
||||||
|
// hash so a link can go straight to one. The server sends this page and this script to admins
|
||||||
|
// only, and refuses every call below to anyone else; they were parts of Settings and the header,
|
||||||
|
// shown or hidden by the main page's script (issue #19).
|
||||||
|
|
||||||
|
let me: {name: string} | null = null;
|
||||||
|
api('/api/me').then(u => { me = u; }).catch(() => {});
|
||||||
|
|
||||||
|
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
|
||||||
|
|
||||||
|
function showSection(){
|
||||||
|
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
|
||||||
|
for(const s of $$('#admin > section')) s.hidden = s.id !== t;
|
||||||
|
for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t);
|
||||||
|
// The log polls every two seconds while it is showing, and not otherwise.
|
||||||
|
if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; }
|
||||||
|
SECTIONS[t]();
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', showSection);
|
||||||
|
|
||||||
|
/* ---------------- server ---------------- */
|
||||||
|
async function drawServer(){
|
||||||
|
const box = $('#server');
|
||||||
|
const g = await api('/api/settings');
|
||||||
|
const gs = splitEvery(g.every_mins);
|
||||||
|
box.innerHTML = `<h2>Server</h2>
|
||||||
|
<p class="hint">These apply to everyone. Each person's own choices, such as keywords or how
|
||||||
|
many items a feed downloads for them, are in that feed's settings.</p>
|
||||||
|
<div class="field"><label>Check feeds every</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||||
|
<select id="gunit">${unitOptions(gs.u)}</select>
|
||||||
|
</div>
|
||||||
|
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
||||||
|
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
||||||
|
<div class="field"><label>Max new downloads per scan, per feed</label>
|
||||||
|
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
||||||
|
<span class="hint">Applies to any feed that does not set its own — including every feed
|
||||||
|
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
|
||||||
|
catalogue the first time a feed is scanned.</span></div>
|
||||||
|
<div class="field"><label>Download these media types automatically</label>
|
||||||
|
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
||||||
|
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
|
||||||
|
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
|
||||||
|
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
|
||||||
|
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
|
||||||
|
<span class="hint">Over this, the oldest played items are deleted first. Pinned
|
||||||
|
items are never touched.</span></div>
|
||||||
|
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
||||||
|
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
||||||
|
<div class="field"><label>Download folder</label>
|
||||||
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||||
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||||
|
$('#gsave').onclick = async () => {
|
||||||
|
try{
|
||||||
|
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
||||||
|
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
|
||||||
|
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
||||||
|
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
|
max_total_gb: Number($('#gquota').value) || 0,
|
||||||
|
max_age_days: Number($('#gage').value) || 0})});
|
||||||
|
toast('Settings saved');
|
||||||
|
}catch(e){ toast(e.message, true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- accounts ---------------- */
|
||||||
|
async function drawAccounts(){
|
||||||
|
const box = $('#accounts');
|
||||||
|
const users = await api('/api/users') || [];
|
||||||
|
box.innerHTML = `<h2>Accounts</h2>
|
||||||
|
${users.map(u => `<div class="inline urow" data-id="${u.id}">
|
||||||
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
|
||||||
|
<small style="display:block;color:var(--faint)">${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
|
||||||
|
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
|
||||||
|
${u.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
|
||||||
|
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin ? 'checked' : ''}> Admin</label>
|
||||||
|
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
|
||||||
|
<div class="field" style="margin-top:20px"><label>Add someone</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
|
||||||
|
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
|
||||||
|
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
||||||
|
through the proxy. New people start with no feeds.</span></div>
|
||||||
|
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
|
||||||
|
const change = async (u, opts) => {
|
||||||
|
try{
|
||||||
|
await api(`/api/users/${u.id}`, opts);
|
||||||
|
// Demoting yourself takes this page away; go back to the app rather than stay on a page
|
||||||
|
// the server no longer answers. Only on success: a refusal's toast has to stay readable.
|
||||||
|
if(u.name === me?.name){ location.href = '/'; return; }
|
||||||
|
}catch(e){ toast(e.message, true); }
|
||||||
|
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
||||||
|
};
|
||||||
|
for(const row of $$('#accounts [data-id]')){
|
||||||
|
const u = users.find(x => String(x.id) === row.dataset.id);
|
||||||
|
$('[data-a="admin"]', row).onchange = e =>
|
||||||
|
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
|
||||||
|
$('[data-a="rm"]', row).onclick = () => {
|
||||||
|
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
||||||
|
change(u, {method: 'DELETE'});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$('#uadd').onclick = async () => {
|
||||||
|
try{
|
||||||
|
await api('/api/users', {method: 'POST', body: JSON.stringify({
|
||||||
|
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
|
||||||
|
toast('Added'); drawAccounts();
|
||||||
|
}catch(e){ toast(e.message, true); } // keep what was typed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- log ---------------- */
|
||||||
|
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
|
||||||
|
// Which sources belong to each tab. "daemon" is the control protocol itself: every
|
||||||
|
// command in and every event out, whatever sent it.
|
||||||
|
const LOG_TABS = {
|
||||||
|
all: null,
|
||||||
|
daemon: t => t === 'ipx::io',
|
||||||
|
scan: t => t === 'ipx::scan',
|
||||||
|
web: t => t === 'ipx::http',
|
||||||
|
};
|
||||||
|
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
||||||
|
|
||||||
|
function drawLogView(){
|
||||||
|
logSeq = 0; logLines = [];
|
||||||
|
$('#log').innerHTML = `<h2>Log</h2>
|
||||||
|
<div class="logbar">
|
||||||
|
<div class="tabs" id="logtabs">
|
||||||
|
${Object.keys(LOG_TABS).map(t =>
|
||||||
|
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
|
||||||
|
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<select id="loglevel" style="width:auto">
|
||||||
|
<option value="">All levels</option>
|
||||||
|
<option value="INFO">Info and above</option>
|
||||||
|
<option value="WARN">Warnings and errors</option>
|
||||||
|
<option value="ERROR">Errors only</option>
|
||||||
|
</select>
|
||||||
|
<input type="search" id="logq" class="grow" placeholder="Filter…">
|
||||||
|
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
|
||||||
|
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
||||||
|
</div>
|
||||||
|
<div id="logbox"><p class="empty">Loading…</p></div>
|
||||||
|
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
|
||||||
|
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
|
||||||
|
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
||||||
|
what it captures.</span>`;
|
||||||
|
for(const b of $$('#logtabs button')) b.onclick = () => {
|
||||||
|
logTab = b.dataset.t;
|
||||||
|
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
||||||
|
drawLog();
|
||||||
|
};
|
||||||
|
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
|
||||||
|
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
|
||||||
|
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
||||||
|
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
|
||||||
|
pollLog();
|
||||||
|
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollLog(){
|
||||||
|
try{
|
||||||
|
const r = await api(`/api/logs?after=${logSeq}&limit=500`);
|
||||||
|
if(r.lines.length){
|
||||||
|
logLines = logLines.concat(r.lines).slice(-2000);
|
||||||
|
logSeq = r.latest;
|
||||||
|
drawLog();
|
||||||
|
}else if(!logLines.length){ drawLog(); }
|
||||||
|
}catch(e){
|
||||||
|
const box = $('#logbox');
|
||||||
|
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function visibleLog(){
|
||||||
|
const min = logLevel ? LEVELS[logLevel] : -1;
|
||||||
|
const tab = LOG_TABS[logTab];
|
||||||
|
return logLines.filter(l =>
|
||||||
|
(!tab || tab(l.target)) &&
|
||||||
|
(LEVELS[l.level] ?? 1) >= min &&
|
||||||
|
(!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter)));
|
||||||
|
}
|
||||||
|
function drawLog(){
|
||||||
|
const box = $('#logbox'); if(!box) return;
|
||||||
|
const follow = $('#logfollow')?.checked;
|
||||||
|
const rows = visibleLog();
|
||||||
|
box.innerHTML = rows.length ? rows.map(l => {
|
||||||
|
const t = new Date(l.ts * 1000).toLocaleTimeString();
|
||||||
|
if(logTab === 'daemon'){
|
||||||
|
const out = l.msg.startsWith('<-');
|
||||||
|
return `<div class="l"><time>${t}</time>` +
|
||||||
|
`<span class="lv" style="color:${out ? 'var(--good)' : 'var(--accent)'}">${out ? 'out' : 'in'}</span>` +
|
||||||
|
`<span>${esc(l.msg.replace(/^[<-]+\s*/, ''))}</span></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>` +
|
||||||
|
`<span class="tg">${esc(l.target.replace(/^ipx::?/, ''))}</span><span>${esc(l.msg)}</span></div>`;
|
||||||
|
}).join('') : '<p class="empty">Nothing matches.</p>';
|
||||||
|
if(follow) box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSection();
|
||||||
398
web/src/dialogs.ts
Normal file
398
web/src/dialogs.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
/* ---------------- modals ---------------- */
|
||||||
|
function openModal(html: string, wide?: boolean){
|
||||||
|
$('#modalCard').innerHTML=html;
|
||||||
|
$('#modalCard').classList.toggle('wide',!!wide);
|
||||||
|
$('#modal').classList.add('on');
|
||||||
|
}
|
||||||
|
function closeModal(){
|
||||||
|
$('#modal').classList.remove('on');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
|
||||||
|
|
||||||
|
$('#addFeed').onclick=()=>{
|
||||||
|
openModal(`<h3>Add a feed</h3>
|
||||||
|
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||||
|
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||||
|
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
|
||||||
|
<div class="field"><label>Keywords (optional, comma separated)</label>
|
||||||
|
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
||||||
|
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
||||||
|
$('#nurl').focus();
|
||||||
|
$('#nsave').onclick=async()=>{
|
||||||
|
const url=$('#nurl').value.trim(); if(!url) return;
|
||||||
|
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
||||||
|
try{
|
||||||
|
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||||||
|
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
|
||||||
|
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
||||||
|
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
||||||
|
await loadFeeds(true); selectFeed(r.id);
|
||||||
|
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
|
||||||
|
// URL, so a key in someone's feed address never reaches this page.
|
||||||
|
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||||||
|
async function listFeeds(url,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=await api(url)||[]; }catch{}
|
||||||
|
box.innerHTML=rows.length?'':NONE_LISTED;
|
||||||
|
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
|
||||||
|
/// parts are the same either way; the class lays them out.
|
||||||
|
function listedFeed(p,cls){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className=cls;
|
||||||
|
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
||||||
|
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
||||||
|
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||||
|
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||||
|
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||||
|
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
||||||
|
// Yours already: the row opens it instead.
|
||||||
|
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
|
||||||
|
$('[data-a="sub"]',el).onclick=async()=>{
|
||||||
|
try{
|
||||||
|
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||||
|
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
|
||||||
|
await loadFeeds(true); selectFeed(p.id);
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
|
||||||
|
// otherwise clear them.
|
||||||
|
let dirKind='All', dirCat=null;
|
||||||
|
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||||
|
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
|
||||||
|
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
|
||||||
|
/// category, as chips). Both filter in place, without asking the server again.
|
||||||
|
async function renderDirectory(url,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=await api(url)||[]; }catch{}
|
||||||
|
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
|
||||||
|
const bar=$('#dirbar');
|
||||||
|
// Only a filter when the server has both kinds.
|
||||||
|
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
|
||||||
|
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
|
||||||
|
const draw=()=>{
|
||||||
|
if(!both) dirKind='All';
|
||||||
|
const ofKind=rows.filter(KINDS[dirKind]);
|
||||||
|
// No empty chips: only the categories among the feeds the kind lets through.
|
||||||
|
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
|
||||||
|
if(!cats.includes(dirCat)) dirCat=null;
|
||||||
|
bar.innerHTML=
|
||||||
|
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
|
||||||
|
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
|
||||||
|
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
|
||||||
|
// the button just pressed.
|
||||||
|
for(const b of $$('button',bar)) b.onclick=()=>{
|
||||||
|
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
|
||||||
|
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
|
||||||
|
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
|
||||||
|
};
|
||||||
|
box.innerHTML='';
|
||||||
|
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory and Popular open in the main pane, as the original's Directory did.
|
||||||
|
async function renderListed(v){
|
||||||
|
const box=$('#content');
|
||||||
|
box.classList.add('plain');
|
||||||
|
$('#tbRemove').disabled=true;
|
||||||
|
syncTools(null);
|
||||||
|
$('#epSearch').placeholder='Search items…';
|
||||||
|
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
|
||||||
|
box.innerHTML=`
|
||||||
|
<div class="fhead slim">
|
||||||
|
<div class="art">${v.icon}</div>
|
||||||
|
<div class="meta"><h2>${v.title}</h2>
|
||||||
|
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
|
||||||
|
</div>
|
||||||
|
${grid?'<div class="dirbar" id="dirbar"></div>':''}
|
||||||
|
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
|
||||||
|
$('#count').textContent=v.title;
|
||||||
|
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
|
||||||
|
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently Listening: episodes you started and have not finished, across every feed you
|
||||||
|
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||||||
|
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||||
|
async function renderListening(url,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||||
|
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||||
|
for(const e of rows){
|
||||||
|
// Carries its episode, for paintListenRow to repaint as the player moves.
|
||||||
|
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.entry=e;
|
||||||
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
|
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
|
||||||
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
||||||
|
`<button class="iconbtn" data-a="play"></button>`+
|
||||||
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
||||||
|
`<div class="rail"><i></i></div>`;
|
||||||
|
el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e)
|
||||||
|
:el.classList.contains('now')&&!audio.paused?audio.pause():play(e);
|
||||||
|
paintListenRow(el);
|
||||||
|
box.appendChild(el);
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
||||||
|
function paintListenRow(el){
|
||||||
|
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
||||||
|
// Zero until the player has sought to where you left off; the saved position stands till then.
|
||||||
|
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
||||||
|
// The player's own length first: a feed's can be minutes out.
|
||||||
|
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
|
||||||
|
el.classList.toggle('now',now);
|
||||||
|
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
|
||||||
|
// With no length there is nothing to show, and an empty rail reads as a heavy border.
|
||||||
|
const rail=$('.rail',el); rail.hidden=!d;
|
||||||
|
$('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`;
|
||||||
|
const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume';
|
||||||
|
if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; }
|
||||||
|
}
|
||||||
|
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
||||||
|
function syncListening(){
|
||||||
|
for(const el of $$('#listening .childrow'))
|
||||||
|
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
|
||||||
|
/// episode with a saved position short of the end, so the position is what has to go.
|
||||||
|
async function forget(e){
|
||||||
|
// Closed without saving first, or the player's next save would put it straight back.
|
||||||
|
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
|
||||||
|
try{
|
||||||
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||||
|
{method:'POST',body:JSON.stringify({secs:0})});
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
||||||
|
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
||||||
|
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||||||
|
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||||||
|
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||||||
|
let searchT;
|
||||||
|
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
||||||
|
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
|
||||||
|
// Crossing the phone breakpoint moves the files between their pane and the text.
|
||||||
|
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
||||||
|
|
||||||
|
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
|
||||||
|
function toggleGroup(id){
|
||||||
|
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
||||||
|
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
|
||||||
|
renderFeeds();
|
||||||
|
}
|
||||||
|
let globalMax = 3;
|
||||||
|
function due(ts){
|
||||||
|
const d = ts - Date.now()/1000;
|
||||||
|
if(d <= 0) return 'due now';
|
||||||
|
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
|
||||||
|
if(d < 86400) return 'in '+Math.round(d/3600)+'h';
|
||||||
|
return 'in '+Math.round(d/86400)+'d';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
||||||
|
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
|
||||||
|
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
||||||
|
async function prefsModal(){
|
||||||
|
const g = await api('/api/settings');
|
||||||
|
const admin = !!(S.me&&S.me.admin);
|
||||||
|
openModal(`<h3>Settings</h3>
|
||||||
|
<div class="field"><label>Theme</label>
|
||||||
|
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||||
|
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||||
|
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
|
||||||
|
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||||
|
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||||
|
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||||
|
<div class="field"><label>Subscriptions</label>
|
||||||
|
<div class="inline">
|
||||||
|
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
||||||
|
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
|
||||||
|
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
|
||||||
|
</div>
|
||||||
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||||
|
subscribes you to every feed in one.</span></div>
|
||||||
|
<div class="field"><label>Feeds are checked every</label>
|
||||||
|
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||||
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
||||||
|
<div class="field"><label>Download folder</label>
|
||||||
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
||||||
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
|
$('#gopml').onclick=opmlModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsModal(f, newUrl?: string){
|
||||||
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||||
|
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||||
|
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
||||||
|
settings apply to it and are inherited by every feed inside it.</p>`:''}
|
||||||
|
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
|
||||||
|
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
|
||||||
|
config.toml, and it stops following the subscription's settings.</p>`:''}
|
||||||
|
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
|
||||||
|
Everyone else keeps their own.</p>
|
||||||
|
<div class="field"><label>Keywords</label>
|
||||||
|
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
||||||
|
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
||||||
|
<div class="field"><label>Max new downloads per scan</label>
|
||||||
|
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
||||||
|
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
|
||||||
|
the next scan.</span></div>
|
||||||
|
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
||||||
|
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
||||||
|
<div class="field"><label>Feed URL</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
||||||
|
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
||||||
|
</div>
|
||||||
|
<span class="hint">${S.me&&S.me.admin
|
||||||
|
? `Shared with everyone reading this feed. Editing it keeps every item and download —
|
||||||
|
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
|
||||||
|
on the next scan.`
|
||||||
|
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
|
||||||
|
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
|
||||||
|
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
||||||
|
<span class="hint">Where the files land. There is one copy however many people
|
||||||
|
subscribe, so this is the same for everyone.</span></div>`:''}
|
||||||
|
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
||||||
|
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
||||||
|
<datalist id="scats"></datalist>
|
||||||
|
<span class="hint">${f.feed_category
|
||||||
|
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
|
||||||
|
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
||||||
|
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
||||||
|
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
||||||
|
// than starting a second chip beside it.
|
||||||
|
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
||||||
|
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
||||||
|
$('#ssave').onclick=async()=>{
|
||||||
|
const max=$('#smax').value;
|
||||||
|
try{
|
||||||
|
const patch: Record<string, unknown>={
|
||||||
|
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
|
||||||
|
max_new_per_check:max===''?null:Number(max),
|
||||||
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
||||||
|
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
||||||
|
if(S.me&&S.me.admin){
|
||||||
|
patch.url=$('#surl').value.trim();
|
||||||
|
patch.folder=$('#sfolder').value.trim()||null;
|
||||||
|
patch.category=$('#scat').value.trim()||null;
|
||||||
|
}
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
|
closeModal(); toast('Saved — applies on the next scan');
|
||||||
|
await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadLatestModal(f){
|
||||||
|
openModal(`<h3>Download latest items</h3>
|
||||||
|
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||||
|
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||||
|
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
|
||||||
|
$('#dgo').onclick=async()=>{
|
||||||
|
const n=Number($('#dcount').value)||5;
|
||||||
|
try{
|
||||||
|
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
||||||
|
{method:'POST',body:JSON.stringify({count:n})});
|
||||||
|
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFeed(f){
|
||||||
|
openModal(`<h3>Unsubscribe?</h3>
|
||||||
|
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
|
||||||
|
reading it keeps it, along with their own read state.
|
||||||
|
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
||||||
|
$('#rgo').onclick=async()=>{
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||||
|
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||||
|
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function opmlModal(){
|
||||||
|
openModal(`<h3>OPML</h3>
|
||||||
|
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
|
||||||
|
<div class="field"><label>Export: save your subscriptions as OPML</label>
|
||||||
|
<div class="inline">
|
||||||
|
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
|
||||||
|
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
|
||||||
|
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
||||||
|
$('#oimp').onclick=async()=>{
|
||||||
|
// A chosen file is read here and sent as text, so the server never stores it. Clearing
|
||||||
|
// the picker lets go of it on this side too, whether it was refused or imported.
|
||||||
|
const pick=$('#opmlFile'), file=pick.files[0];
|
||||||
|
const xml=file ? await file.text() : $('#opmlText').value;
|
||||||
|
const letGo=()=>{ pick.value=''; };
|
||||||
|
// A quick look before sending anything. The server parses it properly and has the last word.
|
||||||
|
if(!/<opml[\s>]/i.test(xml)){
|
||||||
|
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
|
||||||
|
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
|
||||||
|
}catch(e){ letGo(); toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
|
$('#scanAll').onclick=scanAll;
|
||||||
|
$('#prefs').onclick=prefsModal;
|
||||||
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
// the proxy still vouches for them. /api/me says where, when that is the case.
|
||||||
|
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
||||||
|
api('/api/me').then(u=>{
|
||||||
|
S.me=u;
|
||||||
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||||
|
}).catch(()=>{});
|
||||||
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
|
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||||||
|
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
||||||
|
$('#feedlist').onkeydown=ev=>{
|
||||||
|
const row=ev.target.closest('[data-id]'); if(!row) return;
|
||||||
|
const id=row.dataset.id;
|
||||||
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||||||
|
else if(row.classList.contains('group')&&
|
||||||
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||||
|
else return;
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
};
|
||||||
|
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||||
|
$('#scrim').onclick=()=>nav(false);
|
||||||
|
|
||||||
46
web/src/events.ts
Normal file
46
web/src/events.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/* ---------------- live events ---------------- */
|
||||||
|
let sse;
|
||||||
|
function connect(){
|
||||||
|
sse=new EventSource('/api/events');
|
||||||
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
|
const refreshFeeds=soon(()=>loadFeeds(true));
|
||||||
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
|
let fresh={};
|
||||||
|
const tellNew=soon(()=>{
|
||||||
|
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
|
||||||
|
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
|
||||||
|
fresh={};
|
||||||
|
},900);
|
||||||
|
sse.onmessage=m=>{
|
||||||
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
|
if(ev.ev==='progress'){
|
||||||
|
const pct=ev.total?ev.done/ev.total*100:0;
|
||||||
|
// Only the row actually downloading. Without the enclosure id this used to paint
|
||||||
|
// every pending bar at once, so adding a feed looked like it was fetching the lot.
|
||||||
|
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
|
||||||
|
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
|
||||||
|
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
||||||
|
}
|
||||||
|
else if(ev.ev==='download_done'){
|
||||||
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
|
if(bar) bar.classList.remove('live');
|
||||||
|
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
|
||||||
|
}
|
||||||
|
else if(ev.ev==='download_error'){
|
||||||
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
|
if(bar) bar.classList.remove('live');
|
||||||
|
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||||
|
}
|
||||||
|
else if(ev.ev==='feed_done'){
|
||||||
|
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
|
||||||
|
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||||
|
}
|
||||||
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||||
|
// red ! marks the feed instead, and its page says why.
|
||||||
|
else if(ev.ev==='feed_error') refreshFeeds();
|
||||||
|
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
|
||||||
|
};
|
||||||
|
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
loadFeeds();
|
||||||
185
web/src/feedpage.ts
Normal file
185
web/src/feedpage.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/* ---------------- feed page ---------------- */
|
||||||
|
function renderFeed(){
|
||||||
|
const box=$('#content');
|
||||||
|
box.classList.remove('plain');
|
||||||
|
const v=VIEWS[S.feed];
|
||||||
|
if(v&&v.url) return renderListed(v);
|
||||||
|
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||||
|
$('#tbRemove').disabled=!f;
|
||||||
|
syncTools(null);
|
||||||
|
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
||||||
|
const name=f?(f.title||f.id):v.title;
|
||||||
|
$('#epSearch').placeholder=`Search ${name}…`;
|
||||||
|
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||||
|
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
|
||||||
|
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
|
box.innerHTML = (f ? `
|
||||||
|
<div class="fhead slim">
|
||||||
|
${artHTML(f.image,name)}
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${esc(name)}</h2>
|
||||||
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||||||
|
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||||||
|
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
||||||
|
${failBannerHTML(f)}
|
||||||
|
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
||||||
|
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
||||||
|
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
||||||
|
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||||
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
|
</div>
|
||||||
|
</div>` : `
|
||||||
|
<div class="fhead slim">
|
||||||
|
<div class="art">${v.icon}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${v.title}</h2>
|
||||||
|
<div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you
|
||||||
|
subscribe to, newest first · ${unreadAll} unread</div>
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
||||||
|
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
||||||
|
</div>
|
||||||
|
</div>`) + `
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="tabs">
|
||||||
|
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=>
|
||||||
|
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const pane=document.createElement('div');
|
||||||
|
pane.id='split';
|
||||||
|
if(f) pane.className='one';
|
||||||
|
pane.innerHTML='<div id="list">'+sortHead()+
|
||||||
|
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||||
|
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
||||||
|
box.appendChild(pane);
|
||||||
|
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||||||
|
dragSplit(pane);
|
||||||
|
// Nothing is open any more, so nothing is kept on the Unread tab for being open.
|
||||||
|
S.sel=null;
|
||||||
|
showDetail(null);
|
||||||
|
|
||||||
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
||||||
|
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||||
|
S.filter=b.dataset.f; S.offset=0;
|
||||||
|
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||||
|
renderFeed(); loadEntries();
|
||||||
|
});
|
||||||
|
if(f) wireFailBanner(box,f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The item table's headings, each a button that sorts by its column. The first click goes the
|
||||||
|
/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it.
|
||||||
|
const COLS=[['kept','Pinned',ICON.pin],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
|
||||||
|
function sortHead(){
|
||||||
|
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
|
||||||
|
const on=S.sort.col===k;
|
||||||
|
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}${icon?' h-ic':''}" data-sort="${k}"`+
|
||||||
|
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
|
||||||
|
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
||||||
|
}).join('')+'<span></span></div>';
|
||||||
|
}
|
||||||
|
function sortBy(col){
|
||||||
|
const first=['published','size','kept'].includes(col)?'desc':'asc';
|
||||||
|
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
|
||||||
|
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
|
||||||
|
S.offset=0; renderFeed(); loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
||||||
|
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||||
|
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
||||||
|
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||'');
|
||||||
|
|
||||||
|
function renderGroup(f,kids){
|
||||||
|
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
||||||
|
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
||||||
|
const gone=kids.filter(c=>c.orphaned).length;
|
||||||
|
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
|
||||||
|
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
||||||
|
$('#content').innerHTML = `
|
||||||
|
<div class="fhead slim">
|
||||||
|
${folderArt(f,kids)}
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${esc(f.title||f.id)}</h2>
|
||||||
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||||||
|
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||||
|
${failBannerHTML(f)}
|
||||||
|
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
||||||
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||||
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
|
||||||
|
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="childlist" id="kidlist"></div>`;
|
||||||
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
||||||
|
wireFailBanner($('#content'),f);
|
||||||
|
const draw=()=>{
|
||||||
|
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||||
|
const box=$('#kidlist'); box.innerHTML='';
|
||||||
|
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||||
|
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||||
|
for(const c of rows){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
||||||
|
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
||||||
|
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
||||||
|
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
||||||
|
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||||
|
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
||||||
|
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
|
||||||
|
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
||||||
|
el.onclick=()=>selectFeed(c.id);
|
||||||
|
box.appendChild(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$('#kidSearch').oninput=draw;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function feedAction(a,f){
|
||||||
|
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
|
if(a==='rm') removeFeed(f);
|
||||||
|
if(a==='pin'){
|
||||||
|
try{
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
|
||||||
|
await loadFeeds(true); renderFeed();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
}
|
||||||
|
if(a==='settings') settingsModal(f);
|
||||||
|
if(a==='dl') downloadLatestModal(f);
|
||||||
|
}
|
||||||
|
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||||
|
async function allAction(a){
|
||||||
|
if(a==='scanall') return scanAll();
|
||||||
|
if(a==='readall'){
|
||||||
|
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
|
||||||
|
if(!n){ toast('Nothing unread'); return; }
|
||||||
|
// One click across every feed is a lot to take back, so this one asks first.
|
||||||
|
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
|
||||||
|
try{
|
||||||
|
const r=await api('/api/read-all',{method:'POST'});
|
||||||
|
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
129
web/src/feeds.ts
Normal file
129
web/src/feeds.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/* ---------------- feeds ---------------- */
|
||||||
|
async function loadFeeds(keepSel?: boolean){
|
||||||
|
S.feeds = await api('/api/feeds');
|
||||||
|
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||||||
|
renderFeeds();
|
||||||
|
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
||||||
|
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||||||
|
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||||
|
if(!keepSel && S.feeds.length){
|
||||||
|
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||||||
|
selectFeed(known ? S.feed : ':all');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||||
|
// server's alphabetical order still holds within each half.
|
||||||
|
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
|
||||||
|
// The original's source list opened with these, above the feeds. They are places, not feeds:
|
||||||
|
// an id starting with ':' can never be a feed's, since feed ids are slugs.
|
||||||
|
const VIEWS={
|
||||||
|
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
|
||||||
|
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
|
||||||
|
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
|
||||||
|
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
|
||||||
|
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
|
||||||
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
|
};
|
||||||
|
function renderFeeds(){
|
||||||
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
|
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
|
||||||
|
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
|
||||||
|
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
|
||||||
|
const done=()=>{
|
||||||
|
list.scrollTop=top;
|
||||||
|
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||||||
|
if(row) (back[1]&&$('.chev',row)||row).focus();
|
||||||
|
};
|
||||||
|
list.innerHTML='';
|
||||||
|
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||||
|
const places=document.createElement('div');
|
||||||
|
places.className='places';
|
||||||
|
for(const [id,v] of Object.entries(VIEWS)){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='place'+(S.feed===id?' sel':'');
|
||||||
|
el.tabIndex=0; el.dataset.id=id;
|
||||||
|
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
||||||
|
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||||
|
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||||
|
places.appendChild(el);
|
||||||
|
}
|
||||||
|
list.appendChild(places);
|
||||||
|
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
||||||
|
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
||||||
|
|
||||||
|
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
|
||||||
|
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
|
||||||
|
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
||||||
|
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
||||||
|
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
|
||||||
|
const order=[];
|
||||||
|
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
|
||||||
|
order.push([f,0]);
|
||||||
|
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
||||||
|
// opens them all, or matches inside a closed folder would be invisible.
|
||||||
|
if(expanded.has(f.id) || q)
|
||||||
|
for(const c of inside(f).sort(unreadFirst)) order.push([c,1]);
|
||||||
|
}
|
||||||
|
// The rule under the pinned block goes under its last row: a pinned folder's last feed when
|
||||||
|
// it is open.
|
||||||
|
const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned);
|
||||||
|
const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0];
|
||||||
|
for(const [f,depth] of order){
|
||||||
|
const kids=inside(f).length;
|
||||||
|
// A subscription holds no entries itself, so its counts are the sum of what is inside --
|
||||||
|
// taken from every feed it holds, not just the ones a filter left showing.
|
||||||
|
const mine=S.feeds.filter(c=>c.group===f.id);
|
||||||
|
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
|
||||||
|
const [unread,eps,saved]=mine.length
|
||||||
|
? [sum('unread'),sum('entries'),sum('downloaded')]
|
||||||
|
: [f.unread,f.entries,f.downloaded];
|
||||||
|
// A group's own row has no error of its own worth mentioning if the OPML itself
|
||||||
|
// reads fine; it is failing when any feed inside it is. The feed's own page says what
|
||||||
|
// went wrong (failBannerHTML); the list only has to make it findable.
|
||||||
|
const bad=c=>c.failing?.reason||c.last_error;
|
||||||
|
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
|
||||||
|
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
|
||||||
|
el.tabIndex=0; el.dataset.id=f.id;
|
||||||
|
const open = !!(kids && (expanded.has(f.id) || q));
|
||||||
|
el.innerHTML =
|
||||||
|
// The error mark hangs in the margin where a folder's triangle does. A folder already has
|
||||||
|
// its triangle there, so that turns red instead, and the feed inside shows the mark.
|
||||||
|
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
|
||||||
|
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
||||||
|
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
|
||||||
|
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
|
||||||
|
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
||||||
|
`</small></div>`+
|
||||||
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||||
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||||
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
|
list.appendChild(el);
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
function selectFeed(id){
|
||||||
|
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||||||
|
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||||||
|
renderFeeds(); renderFeed(); loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failing feed's error, in plain words with something to do about it, once `failing` is
|
||||||
|
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||||||
|
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||||
|
function failBannerHTML(f){
|
||||||
|
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||||
|
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||||||
|
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||||||
|
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function wireFailBanner(box,f){
|
||||||
|
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
||||||
|
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
|
||||||
|
}
|
||||||
|
|
||||||
83
web/src/gestures.ts
Normal file
83
web/src/gestures.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/* ---------------- touch gestures ---------------- */
|
||||||
|
// On a touch screen: pull the item list down from its top to check the feed for new items, and
|
||||||
|
// swipe the item you are reading left for the next one, right for the one before, or back to
|
||||||
|
// the list from the first. Touch events only, so a mouse never sets these off. Listened for on
|
||||||
|
// the document because the panes are rebuilt every time a feed renders.
|
||||||
|
|
||||||
|
const PULL = 70; // px down, from the list's top, that counts as a pull
|
||||||
|
const SWIPE = 60; // px across that counts as a swipe
|
||||||
|
|
||||||
|
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
|
||||||
|
|
||||||
|
/// Something that scrolls sideways, or takes typing, keeps its own gestures: a wide code block
|
||||||
|
/// or table in a post, the player's seek bar, a text box.
|
||||||
|
function ownsSwipe(el: Element | null){
|
||||||
|
for(let n = el; n && n.id !== 'detail'; n = n.parentElement){
|
||||||
|
if(/^(INPUT|TEXTAREA|SELECT|AUDIO|VIDEO)$/.test(n.tagName)) return true;
|
||||||
|
if(n.scrollWidth > n.clientWidth + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the list has been pulled: a note at its top, growing with the pull and pushing the
|
||||||
|
/// items down, that says what letting go will do.
|
||||||
|
function pullShow(dy: number){
|
||||||
|
const list = $('#list'); if(!list) return;
|
||||||
|
const d = Math.round(Math.min(dy, PULL * 1.6) / 2);
|
||||||
|
let tip = $('#pulltip');
|
||||||
|
if(!d){ tip?.remove(); return; }
|
||||||
|
if(!tip){ tip = document.createElement('div'); tip.id = 'pulltip'; list.prepend(tip); }
|
||||||
|
tip.style.height = d + 'px';
|
||||||
|
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFeed(){
|
||||||
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
|
if(!f && S.feed !== ':all') return;
|
||||||
|
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
|
||||||
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
|
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
|
.catch(e => toast(e.message, true));
|
||||||
|
loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', ev => {
|
||||||
|
touch = null;
|
||||||
|
if(ev.touches.length !== 1 || $('#modal')?.classList.contains('on')) return;
|
||||||
|
const t = ev.touches[0], target = ev.target as Element;
|
||||||
|
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
|
||||||
|
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
|
||||||
|
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
|
||||||
|
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
||||||
|
}, {passive: true});
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', ev => {
|
||||||
|
if(!touch) return;
|
||||||
|
const t = ev.touches[0];
|
||||||
|
touch.dx = t.clientX - touch.x; touch.dy = t.clientY - touch.y;
|
||||||
|
if(touch.pane === 'list'){
|
||||||
|
// Only a pull that starts at the top and goes down; anything else is an ordinary scroll.
|
||||||
|
if(touch.dy < 0 || $('#list').scrollTop > 0){ pullShow(0); touch = null; return; }
|
||||||
|
pullShow(touch.dy);
|
||||||
|
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
|
||||||
|
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
|
||||||
|
touch = null; // scrolling the text, not swiping; the first few px
|
||||||
|
// decide nothing, being mostly jitter
|
||||||
|
}
|
||||||
|
}, {passive: false});
|
||||||
|
|
||||||
|
document.addEventListener('touchend', () => {
|
||||||
|
const g = touch; touch = null;
|
||||||
|
if(!g) return;
|
||||||
|
if(g.pane === 'list'){
|
||||||
|
pullShow(0);
|
||||||
|
if(g.dy >= PULL) refreshFeed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Across, mostly sideways, and not a slow drag while selecting text.
|
||||||
|
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
|
||||||
|
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||||
|
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; }
|
||||||
|
if(i > 0) stepEntry(-1); else showDetail(null);
|
||||||
|
});
|
||||||
|
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });
|
||||||
344
web/src/items.ts
Normal file
344
web/src/items.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
/* ---------------- items ---------------- */
|
||||||
|
let loadSeq=0;
|
||||||
|
async function loadEntries(append?: boolean){
|
||||||
|
// Directory and Popular list feeds, not items.
|
||||||
|
if(!S.feed || VIEWS[S.feed]?.url) return;
|
||||||
|
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
|
||||||
|
if(S.q) p.set('q',S.q);
|
||||||
|
const asked=performance.now(), seq=++loadSeq;
|
||||||
|
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||||
|
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||||
|
// Only the latest list counts: marking everything read reloads the All tab, and clicking
|
||||||
|
// Unread straight after could have that answer land last and replace the Unread one.
|
||||||
|
if(seq!==loadSeq) return;
|
||||||
|
S.total=r.total;
|
||||||
|
for(const e of r.entries){
|
||||||
|
const w=readWrites.get(readKey(e));
|
||||||
|
if(w && !(w.done<asked)) e.read=w.read;
|
||||||
|
}
|
||||||
|
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||||
|
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||||
|
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
||||||
|
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||||||
|
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel);
|
||||||
|
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
|
||||||
|
const open=S.entries.find(e=>e.guid===S.sel);
|
||||||
|
if(open) entries=[open,...entries];
|
||||||
|
}
|
||||||
|
S.entries = entries;
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
function renderEntries(){
|
||||||
|
const box=$('#eps'); if(!box) return;
|
||||||
|
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
|
||||||
|
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
|
$('#count').textContent=
|
||||||
|
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||||
|
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
||||||
|
box.innerHTML='';
|
||||||
|
if(!S.entries.length){
|
||||||
|
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(const e of S.entries) box.appendChild(epEl(e));
|
||||||
|
syncPlayButtons();
|
||||||
|
if(pane) pane.scrollTop=top;
|
||||||
|
if(S.entries.length < S.total){
|
||||||
|
const b=document.createElement('button');
|
||||||
|
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
|
||||||
|
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
|
||||||
|
box.appendChild(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function epEl(e){
|
||||||
|
// An item may carry several files. The row summarises the one you would act on --
|
||||||
|
// the playable one, else anything already downloaded, else the first -- and says how
|
||||||
|
// many others there are; the pane below lists them all.
|
||||||
|
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
|
||||||
|
const has=!!(enc&&enc.path);
|
||||||
|
const playable=isPlayable(enc);
|
||||||
|
const others=e.enclosures.length-1;
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
|
||||||
|
el.dataset.guid=e.guid;
|
||||||
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
|
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
|
||||||
|
el.innerHTML=`
|
||||||
|
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||||
|
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||||
|
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
|
||||||
|
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<div class="body">
|
||||||
|
<span class="t">${esc(e.title||'(untitled)')}</span>
|
||||||
|
<div class="line">${[
|
||||||
|
num&&`<span>${num}</span>`,
|
||||||
|
left&&`<span>${left}</span>`,
|
||||||
|
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
||||||
|
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
||||||
|
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
||||||
|
</div>
|
||||||
|
<span class="fd">${esc(feedName(e.feed_id))}</span>
|
||||||
|
<div class="file">
|
||||||
|
${enc?kindIcon(enc):''}
|
||||||
|
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
||||||
|
</div>
|
||||||
|
<span class="size">${enc?mb(enc.length):''}</span>
|
||||||
|
<span class="date">${dateOf(e.published)}</span>
|
||||||
|
<div class="rowacts">
|
||||||
|
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
|
||||||
|
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
|
||||||
|
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
|
||||||
|
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
||||||
|
</div>`;
|
||||||
|
el.onclick=()=>selectEntry(e);
|
||||||
|
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
/// Whether the browser can play it. Having a file is not the same as being playable:
|
||||||
|
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||||
|
/// is just a broken player.
|
||||||
|
function isPlayable(x){
|
||||||
|
if(!x || !x.path) return false;
|
||||||
|
const m=(x.mime||'').toLowerCase();
|
||||||
|
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||||
|
if(m) return false;
|
||||||
|
// No declared type: fall back to the file's extension.
|
||||||
|
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
|
||||||
|
.test((x.path||x.url||'').split('?')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
||||||
|
function kindOf(enc){
|
||||||
|
const m=(enc.mime||'').toLowerCase();
|
||||||
|
if(m.startsWith('image/')) return 'image';
|
||||||
|
if(m.startsWith('video/')) return 'video';
|
||||||
|
if(m.startsWith('audio/')) return 'audio';
|
||||||
|
if(m.includes('pdf')) return 'pdf';
|
||||||
|
if(m.includes('torrent')) return 'torrent';
|
||||||
|
const ext=(enc.url||'').split('?')[0].split('.').pop();
|
||||||
|
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
|
||||||
|
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
|
||||||
|
/// either way keeps the column lined up; the words are in its tooltip.
|
||||||
|
function kindIcon(enc){
|
||||||
|
const k=kindOf(enc);
|
||||||
|
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
|
||||||
|
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
|
||||||
|
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
||||||
|
: ['',k];
|
||||||
|
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
||||||
|
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||||
|
|
||||||
|
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||||
|
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||||
|
function swapRow(e){
|
||||||
|
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
||||||
|
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and unread as this page last set them, and when the server had it. A list asked for
|
||||||
|
/// before then answers with the old state: it put the dot back on an item just read, until
|
||||||
|
/// the next refresh took it off again (loadEntries).
|
||||||
|
const readWrites=new Map();
|
||||||
|
const readKey=e=>e.feed_id+'\n'+e.guid;
|
||||||
|
function setRead(e,read){
|
||||||
|
e.read=read;
|
||||||
|
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||||
|
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||||
|
{method:'POST',body:JSON.stringify({read})})
|
||||||
|
.then(()=>{ w.done=performance.now(); loadFeeds(true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||||
|
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||||
|
function markRead(e){
|
||||||
|
if(e.read) return;
|
||||||
|
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectEntry(e){
|
||||||
|
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
||||||
|
// next happens to come along, which left a few read ones in the list for a while.
|
||||||
|
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel);
|
||||||
|
if(prev&&prev.read){
|
||||||
|
S.entries=S.entries.filter(x=>x!==prev); S.total--;
|
||||||
|
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
|
||||||
|
}
|
||||||
|
S.sel=e.guid;
|
||||||
|
markRead(e);
|
||||||
|
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
|
||||||
|
swapRow(e);
|
||||||
|
showDetail(e);
|
||||||
|
const d=$('#detail'); if(d) d.scrollTop=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drag the divider between the item list and the item text.
|
||||||
|
function dragSplit(pane){
|
||||||
|
const grab=$('#grab',pane);
|
||||||
|
if(!grab) return;
|
||||||
|
const move=ev=>{
|
||||||
|
const box=pane.getBoundingClientRect();
|
||||||
|
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
||||||
|
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
||||||
|
};
|
||||||
|
const stop=()=>{
|
||||||
|
document.removeEventListener('mousemove',move);
|
||||||
|
document.removeEventListener('mouseup',stop);
|
||||||
|
document.body.style.userSelect='';
|
||||||
|
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
||||||
|
};
|
||||||
|
grab.onmousedown=ev=>{
|
||||||
|
ev.preventDefault();
|
||||||
|
document.body.style.userSelect='none';
|
||||||
|
document.addEventListener('mousemove',move);
|
||||||
|
document.addEventListener('mouseup',stop);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which item the toolbar's play, read and keep buttons act on.
|
||||||
|
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
|
||||||
|
// the first. Key rows by feed as well if that ever happens.
|
||||||
|
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
||||||
|
function syncTools(e){
|
||||||
|
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||||
|
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||||
|
// The same icons as the item's own buttons beside its title, so the two never disagree.
|
||||||
|
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
|
||||||
|
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||||
|
if(e){
|
||||||
|
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||||
|
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||||
|
function showDetail(e){
|
||||||
|
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||||
|
// A pane that can only say "No files" gives its width to the list instead.
|
||||||
|
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||||
|
document.body.classList.toggle('reading',!!e);
|
||||||
|
syncTools(e);
|
||||||
|
if(!e){
|
||||||
|
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
|
||||||
|
if(files) files.innerHTML='<p class="empty">No files</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
||||||
|
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
||||||
|
const encs=e.enclosures.map(encBox).join('');
|
||||||
|
// A phone has no room for the files pane, so the files go above the text there instead.
|
||||||
|
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||||
|
// it looked as if a downloaded file could not be deleted at all (issue #21).
|
||||||
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
|
box.innerHTML=`
|
||||||
|
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
||||||
|
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
||||||
|
<div class="dmeta">
|
||||||
|
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
||||||
|
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
|
||||||
|
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
||||||
|
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||||
|
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
|
||||||
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||||
|
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
|
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
|
||||||
|
</div>
|
||||||
|
${narrow?encs:''}
|
||||||
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||||
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||||
|
// description was sanitized server-side with ammonia before it ever reached here
|
||||||
|
$('#dback').onclick=()=>showDetail(null);
|
||||||
|
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||||
|
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||||
|
syncPlayButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||||
|
function encBox(x){
|
||||||
|
const size=x.length?mb(x.length):'';
|
||||||
|
// One file serves everyone reading the feed, so deleting is not a private act.
|
||||||
|
const f=S.feeds.find(y=>y.id===x.feed_id);
|
||||||
|
const shared=f&&f.subscribers>1;
|
||||||
|
// Icons, with the words in the tooltip and for screen readers.
|
||||||
|
const delLabel=shared
|
||||||
|
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
|
||||||
|
: 'Delete file';
|
||||||
|
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||||
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
||||||
|
if(x.path && !isPlayable(x)){
|
||||||
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
|
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
||||||
|
${saveBtn}
|
||||||
|
${delBtn}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if(x.path){
|
||||||
|
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
|
||||||
|
// started the bar as well, so the same file played twice at once.
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
|
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
||||||
|
${saveBtn}
|
||||||
|
${delBtn}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
||||||
|
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
|
||||||
|
// through here, which would make ipx a fetch-anything relay.
|
||||||
|
const viewable = !isPlayable(x) && x.state !== 'pending';
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
|
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||||
|
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
||||||
|
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function epAction(a: string, e, el, encId?: number){
|
||||||
|
// Swap the row in place and refresh the text below when it is the one being read.
|
||||||
|
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
|
||||||
|
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
||||||
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
|
try{
|
||||||
|
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||||
|
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||||
|
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||||
|
if(a==='get'){
|
||||||
|
if(!enc) return;
|
||||||
|
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
||||||
|
toast('Queued: '+(e.title||'item'));
|
||||||
|
}
|
||||||
|
if(a==='del'){
|
||||||
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
const shared=f&&f.subscribers>1;
|
||||||
|
if(!confirm(shared
|
||||||
|
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
|
||||||
|
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
|
||||||
|
+`and will not be downloaded again automatically.`
|
||||||
|
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
|
||||||
|
try{
|
||||||
|
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
||||||
|
}catch(err){
|
||||||
|
// 409: somebody else has it starred or unplayed. Their reason, their words.
|
||||||
|
if(!/one copy of this file/.test(err.message)) throw err;
|
||||||
|
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
|
||||||
|
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
||||||
|
}
|
||||||
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
||||||
|
}
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
}
|
||||||
|
|
||||||
15
web/src/login.ts
Normal file
15
web/src/login.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
document.getElementById('f').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
msg.textContent = '';
|
||||||
|
const r = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: (document.getElementById('name') as HTMLInputElement).value,
|
||||||
|
password: (document.getElementById('pw') as HTMLInputElement).value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (r.ok) location.href = '/';
|
||||||
|
else msg.textContent = await r.text() || 'Sign in failed';
|
||||||
|
};
|
||||||
198
web/src/player.ts
Normal file
198
web/src/player.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/* ---------------- player ---------------- */
|
||||||
|
const audio=$('#audio');
|
||||||
|
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean,
|
||||||
|
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
||||||
|
|
||||||
|
// Marked read when an item has actually been listened to -- at the end, or past 90%.
|
||||||
|
// NOT on play: doing that made the item vanish from the Unread list the instant
|
||||||
|
// you pressed play, which looks exactly like it went missing.
|
||||||
|
function markPlayed(){
|
||||||
|
if(!player.guid||player.marked) return;
|
||||||
|
player.marked=true;
|
||||||
|
const e=player.entry;
|
||||||
|
if(!e||e.read) return;
|
||||||
|
setRead(e,true).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||||||
|
function play(e,enc=e.enclosures.find(isPlayable)){
|
||||||
|
if(!isPlayable(enc)){
|
||||||
|
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The same file carries on where it was; another of the item's files starts from its top.
|
||||||
|
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||||||
|
// Every play button is a pause button for what is playing, as the player bar's is.
|
||||||
|
if(resuming && !audio.paused){ audio.pause(); return; }
|
||||||
|
if(!resuming){
|
||||||
|
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
|
||||||
|
document.body.classList.toggle('has-video', kindOf(enc)==='video');
|
||||||
|
audio.src=`/media/${enc.id}`;
|
||||||
|
audio.currentTime=0;
|
||||||
|
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||||
|
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
||||||
|
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
|
||||||
|
$('#ptitle').textContent=e.title||'(untitled)';
|
||||||
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
$('#pfeed').textContent=f?(f.title||f.id):'';
|
||||||
|
$('#player').classList.add('on');
|
||||||
|
mediaSession(e,f);
|
||||||
|
player.marked=false;
|
||||||
|
}
|
||||||
|
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
function mediaSession(e,f){
|
||||||
|
if(!('mediaSession' in navigator)) return;
|
||||||
|
navigator.mediaSession.metadata=new MediaMetadata({
|
||||||
|
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
||||||
|
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
|
||||||
|
});
|
||||||
|
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||||
|
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
||||||
|
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
|
||||||
|
}
|
||||||
|
audio.addEventListener('timeupdate',()=>{
|
||||||
|
const d=audio.duration||player.entry?.duration||0;
|
||||||
|
$('#pcur').textContent=clock(audio.currentTime);
|
||||||
|
$('#pdur').textContent=clock(d);
|
||||||
|
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||||
|
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||||
|
// that would write back whatever the list said, however old.
|
||||||
|
if(!audio.paused) player.moved=true;
|
||||||
|
// Persist roughly every 10s so a reload resumes where you were. Either way: a jump back used
|
||||||
|
// to wait for the next pause to be saved.
|
||||||
|
if(player.guid && Math.abs(audio.currentTime-player.saveAt)>10){ savePos(); }
|
||||||
|
if(d && audio.currentTime/d >= 0.9) markPlayed();
|
||||||
|
});
|
||||||
|
function savePos(){
|
||||||
|
// Before the file has loaded, currentTime is 0 rather than where you are: saving it then --
|
||||||
|
// a failed load, or a pause before the seek to where you left off -- wiped the position.
|
||||||
|
// Nor from a player nobody has played since it last saved: one left paused in another tab
|
||||||
|
// saved its older place as that tab reloaded, over where you had got to since.
|
||||||
|
if(!player.guid||!audio.readyState||!player.moved) return;
|
||||||
|
player.moved=false;
|
||||||
|
player.saveAt=audio.currentTime;
|
||||||
|
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
|
||||||
|
// The measured length stands in for one the feed left out: without it Currently Listening
|
||||||
|
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
|
||||||
|
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
||||||
|
navigator.sendBeacon?.(
|
||||||
|
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
|
||||||
|
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||||
|
}
|
||||||
|
audio.addEventListener('pause',savePos);
|
||||||
|
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
||||||
|
// body.playing is what sets the EQ bars moving.
|
||||||
|
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||||
|
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||||
|
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
|
||||||
|
|
||||||
|
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files
|
||||||
|
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
|
||||||
|
function syncPlayButtons(){
|
||||||
|
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
||||||
|
const paint=(b,now,idle)=>{
|
||||||
|
const label=now?'Pause':idle;
|
||||||
|
if(b.title===label) return;
|
||||||
|
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
|
||||||
|
};
|
||||||
|
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play');
|
||||||
|
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
|
||||||
|
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
|
||||||
|
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
||||||
|
}
|
||||||
|
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
|
||||||
|
window.addEventListener('beforeunload',savePos);
|
||||||
|
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||||
|
$('#pback').onclick=()=>audio.currentTime-=15;
|
||||||
|
$('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||||
|
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
|
||||||
|
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
||||||
|
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
|
||||||
|
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
||||||
|
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||||
|
syncListening()};
|
||||||
|
(function restore(){
|
||||||
|
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||||
|
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
||||||
|
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('keydown',ev=>{
|
||||||
|
// Escape leaves a dialog even from inside one of its boxes. It used to sit below the check
|
||||||
|
// that follows, so Add feed, which opens with the cursor in its URL box, ignored it.
|
||||||
|
if(ev.key==='Escape'){closeModal();nav(false);return}
|
||||||
|
// The rest are single keys that would otherwise eat what you type.
|
||||||
|
if(/^(INPUT|TEXTAREA|SELECT)$/.test((ev.target as Element).tagName)) return;
|
||||||
|
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
|
||||||
|
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||||
|
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
||||||
|
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
|
||||||
|
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
|
||||||
|
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
|
||||||
|
// own shortcuts still work, or while a dialog is open.
|
||||||
|
const GO={a:':all',d:':directory',p:':popular',l:':listening'};
|
||||||
|
let gAt=0;
|
||||||
|
function stepEntry(by){
|
||||||
|
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
||||||
|
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
||||||
|
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||||||
|
selectEntry(e);
|
||||||
|
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
|
}
|
||||||
|
function stepFeed(by){
|
||||||
|
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
|
||||||
|
if(!id) return;
|
||||||
|
selectFeed(id);
|
||||||
|
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
|
}
|
||||||
|
const KEYS={
|
||||||
|
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
|
||||||
|
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
||||||
|
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||||||
|
// they are disabled.
|
||||||
|
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
|
||||||
|
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
|
||||||
|
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||||||
|
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
||||||
|
'[':()=>matchMedia('(max-width:820px)').matches
|
||||||
|
? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'),
|
||||||
|
'?':()=>keysModal(),
|
||||||
|
g:()=>{ gAt=Date.now(); },
|
||||||
|
};
|
||||||
|
function typed(ev){
|
||||||
|
// The second key of a g pair counts only if it follows within a second and a half.
|
||||||
|
const pair=Date.now()-gAt<1500; gAt=0;
|
||||||
|
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
|
||||||
|
if(!fn) return;
|
||||||
|
ev.preventDefault(); fn();
|
||||||
|
}
|
||||||
|
/// What ? shows: every key, grouped as Feedly's own list is.
|
||||||
|
function keysModal(){
|
||||||
|
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
|
||||||
|
const rows=[
|
||||||
|
['Go to'],
|
||||||
|
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
|
||||||
|
[g('l'),'Currently Listening'],[g('s'),'Settings'],
|
||||||
|
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
|
||||||
|
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
|
||||||
|
['Items'],
|
||||||
|
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
|
||||||
|
[k('Shift')+' '+k('A'),'Mark all read'],
|
||||||
|
['The selected item'],
|
||||||
|
[k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'],
|
||||||
|
[k('v'),'Open the original in a new tab'],
|
||||||
|
['The player'],
|
||||||
|
[k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'],
|
||||||
|
['Anywhere'],
|
||||||
|
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||||||
|
];
|
||||||
|
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
||||||
|
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
||||||
|
}
|
||||||
|
|
||||||
58
web/src/theme.ts
Normal file
58
web/src/theme.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/* ---------------- theme ---------------- */
|
||||||
|
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on
|
||||||
|
// the account, so it follows you to another browser or computer. The server writes it onto the
|
||||||
|
// page's <html> tag (data-theme, data-choice) so the page is drawn in it from the start. The
|
||||||
|
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||||
|
// the system, so no palette is written twice.
|
||||||
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||||
|
modern: {name: 'Modern', modes: true},
|
||||||
|
classic: {name: 'Classic, the 2004 Mac app', modes: false},
|
||||||
|
dracula: {name: 'Dracula', modes: true},
|
||||||
|
material: {name: 'Material', modes: true},
|
||||||
|
adwaita: {name: 'Adwaita', modes: true},
|
||||||
|
flatremix: {name: 'Flat Remix', modes: true},
|
||||||
|
paper: {name: 'Paper', modes: false},
|
||||||
|
nordic: {name: 'Nordic', modes: true},
|
||||||
|
};
|
||||||
|
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||||
|
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||||
|
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
|
||||||
|
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||||
|
const theme = {name: 'modern', mode: 'dark'};
|
||||||
|
|
||||||
|
/// `save` for a choice made in Settings, which goes to the account; not for applying one.
|
||||||
|
function setTheme(name = theme.name, mode = theme.mode, save = false){
|
||||||
|
theme.name = THEMES[name] ? name : 'modern';
|
||||||
|
theme.mode = MODES[mode] ? mode : 'dark';
|
||||||
|
const both = THEMES[theme.name].modes;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = theme.name;
|
||||||
|
// A theme with one palette has it whatever the mode; both of those are light.
|
||||||
|
root.dataset.mode = !both ? 'light'
|
||||||
|
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||||
|
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
||||||
|
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
||||||
|
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||||
|
if(save) saveTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One save at a time, each sending the choice as it stands when it goes. Sent as they came,
|
||||||
|
/// several at once, a quick run through the list could reach the server out of order and
|
||||||
|
/// leave the account on a theme passed on the way.
|
||||||
|
let themeSaving = Promise.resolve();
|
||||||
|
function saveTheme(){
|
||||||
|
themeSaving = themeSaving
|
||||||
|
.then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})}))
|
||||||
|
.catch(e => toast(`Your theme was not saved: ${e.message}`, true));
|
||||||
|
}
|
||||||
|
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||||
|
(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
|
||||||
|
// Nothing on the account yet. A theme this browser kept, from before themes were kept on the
|
||||||
|
// account, goes up to it once, so nobody has to choose again.
|
||||||
|
let name: string | null = null, mode: string | null = null;
|
||||||
|
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
||||||
|
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||||
|
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
||||||
|
})();
|
||||||
178
web/src/util.ts
Normal file
178
web/src/util.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use strict';
|
||||||
|
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
|
||||||
|
// smoke test, not the type checker, is what makes sure a selector exists.
|
||||||
|
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
|
||||||
|
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
|
||||||
|
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
|
||||||
|
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
|
||||||
|
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
|
||||||
|
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
||||||
|
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||||
|
const ICON={
|
||||||
|
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||||
|
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||||
|
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
||||||
|
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
|
||||||
|
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
|
||||||
|
// Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular
|
||||||
|
// and solid pair (Font Awesome's free set has no regular thumbtack). Both share a viewBox padded
|
||||||
|
// for the outline's stroke, so the two draw the same size.
|
||||||
|
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
|
||||||
|
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
|
||||||
|
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
|
||||||
|
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
|
||||||
|
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
|
||||||
|
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
||||||
|
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
||||||
|
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
||||||
|
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
|
||||||
|
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
||||||
|
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
||||||
|
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
||||||
|
popular:fa('0 0 576 512','<path fill="currentColor" d="M309.5-18.9c-4.1-8-12.4-13.1-21.4-13.1s-17.3 5.1-21.4 13.1L193.1 125.3 33.2 150.7c-8.9 1.4-16.3 7.7-19.1 16.3s-.5 18 5.8 24.4l114.4 114.5-25.2 159.9c-1.4 8.9 2.3 17.9 9.6 23.2s16.9 6.1 25 2L288.1 417.6 432.4 491c8 4.1 17.7 3.3 25-2s11-14.2 9.6-23.2L441.7 305.9 556.1 191.4c6.4-6.4 8.6-15.8 5.8-24.4s-10.1-14.9-19.1-16.3L383 125.3 309.5-18.9z"/>'), // solid/star
|
||||||
|
all:fa('0 0 512 512','<path fill="currentColor" d="M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"/>'), // solid/layer-group
|
||||||
|
unread:fa('0 0 512 512','<path fill="currentColor" d="M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z"/>'), // solid/envelope: a closed letter, not a record button
|
||||||
|
audio:fa('0 0 448 512','<path fill="currentColor" d="M64 224c0-88.4 71.6-160 160-160s160 71.6 160 160l0 37.5c-10-3.5-20.8-5.5-32-5.5l-16 0c-26.5 0-48 21.5-48 48l0 128c0 26.5 21.5 48 48 48l16 0c53 0 96-43 96-96l0-160C448 100.3 347.7 0 224 0S0 100.3 0 224L0 384c0 53 43 96 96 96l16 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-16 0c-11.2 0-22 1.9-32 5.5L64 224z"/>'), // solid/headphones
|
||||||
|
video:fa('0 0 576 512','<path fill="currentColor" d="M96 64c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L96 64zM464 336l73.5 58.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2L464 176 464 336z"/>'), // solid/video
|
||||||
|
image:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm64 80a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM272 224c8.4 0 16.1 4.4 20.5 11.5l88 144c4.5 7.4 4.7 16.7 .5 24.3S368.7 416 360 416L88 416c-8.9 0-17.2-5-21.3-12.9s-3.5-17.5 1.6-24.8l56-80c4.5-6.4 11.8-10.2 19.7-10.2s15.2 3.8 19.7 10.2l26.4 37.8 61.4-100.5c4.4-7.1 12.1-11.5 20.5-11.5z"/>'), // solid/image
|
||||||
|
doc:fa('0 0 576 512','<path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l80 0 0-112c0-35.3 28.7-64 64-64l176 0 0-165.5c0-17-6.7-33.3-18.7-45.3L290.7 18.7C278.7 6.7 262.5 0 245.5 0L96 0zM357.5 176L264 176c-13.3 0-24-10.7-24-24L240 58.5 357.5 176zM240 380c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-28 12 0c33.1 0 60-26.9 60-60s-26.9-60-60-60l-32 0zm32 80l-12 0 0-40 12 0c11 0 20 9 20 20s-9 20-20 20zm96-80c-11 0-20 9-20 20l0 128c0 11 9 20 20 20l32 0c28.7 0 52-23.3 52-52l0-64c0-28.7-23.3-52-52-52l-32 0zm20 128l0-88 12 0c6.6 0 12 5.4 12 12l0 64c0 6.6-5.4 12-12 12l-12 0zm88-108l0 128c0 11 9 20 20 20s20-9 20-20l0-44 28 0c11 0 20-9 20-20s-9-20-20-20l-28 0 0-24 28 0c11 0 20-9 20-20s-9-20-20-20l-48 0c-11 0-20 9-20 20z"/>'), // solid/file-pdf
|
||||||
|
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
||||||
|
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
||||||
|
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
||||||
|
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
|
||||||
|
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
||||||
|
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
||||||
|
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
||||||
|
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
|
||||||
|
alert:fa('0 0 128 512','<path fill="currentColor" d="M64 432c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40c0-22.1 17.9-40 40-40zM64 0c26.5 0 48 21.5 48 48 0 .6 0 1.1 0 1.7l-16 304c-.9 17-15 30.3-32 30.3S33 370.7 32 353.7L16 49.7c0-.6 0-1.1 0-1.7 0-26.5 21.5-48 48-48z"/>'), // solid/exclamation
|
||||||
|
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
|
||||||
|
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
|
||||||
|
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
|
||||||
|
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
||||||
|
};
|
||||||
|
// What is playing, as the icon's EQ bars; the stylesheet moves them.
|
||||||
|
const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
||||||
|
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||||
|
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
||||||
|
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
||||||
|
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
|
||||||
|
|
||||||
|
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||||
|
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
|
||||||
|
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||||
|
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
|
||||||
|
return r.status===204?null:r.json().catch(()=>null);
|
||||||
|
}
|
||||||
|
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||||
|
// address it is undefined, so fall back to the old selection-based copy.
|
||||||
|
async function copyText(text,btn){
|
||||||
|
const flash=ok=>{
|
||||||
|
if(!btn) return;
|
||||||
|
// The button is an icon, so it is the markup that has to come back, not just its text.
|
||||||
|
const was=btn.innerHTML;
|
||||||
|
btn.textContent=ok?'Copied':'Failed';
|
||||||
|
setTimeout(()=>btn.innerHTML=was,1300);
|
||||||
|
};
|
||||||
|
try{
|
||||||
|
if(navigator.clipboard&&window.isSecureContext){
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}else{
|
||||||
|
const ta=document.createElement('textarea');
|
||||||
|
ta.value=text; ta.setAttribute('readonly','');
|
||||||
|
ta.style.cssText='position:fixed;top:-1000px;opacity:0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select(); ta.setSelectionRange(0,ta.value.length);
|
||||||
|
const ok=document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
if(!ok) throw new Error('copy rejected');
|
||||||
|
}
|
||||||
|
flash(true);
|
||||||
|
}catch(e){
|
||||||
|
flash(false);
|
||||||
|
toast('Could not copy automatically — select the URL and copy it manually',true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg: string, bad?: boolean){
|
||||||
|
const t=document.createElement('div');
|
||||||
|
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||||
|
$('#toasts').appendChild(t);
|
||||||
|
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
||||||
|
}
|
||||||
|
const clock = s => {
|
||||||
|
s=Math.max(0,Math.floor(s||0));
|
||||||
|
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
|
||||||
|
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
|
||||||
|
};
|
||||||
|
const ago = t => {
|
||||||
|
if(!t) return 'never';
|
||||||
|
const d=(Date.now()/1000)-t;
|
||||||
|
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
|
||||||
|
if(d<86400) return Math.round(d/3600)+'h ago';
|
||||||
|
if(d<2592000) return Math.round(d/86400)+'d ago';
|
||||||
|
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
|
||||||
|
};
|
||||||
|
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
|
||||||
|
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
|
||||||
|
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||||||
|
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
||||||
|
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
||||||
|
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
|
||||||
|
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
|
||||||
|
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
|
||||||
|
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
|
||||||
|
function tileHTML(name,cls){
|
||||||
|
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
||||||
|
}
|
||||||
|
function artHTML(url: string | null, name: string, cls?: string){
|
||||||
|
return url
|
||||||
|
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||||
|
: tileHTML(name,cls);
|
||||||
|
}
|
||||||
|
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
||||||
|
function folderArt(f,kids){
|
||||||
|
const art=kids.filter(c=>c.image).slice(0,4);
|
||||||
|
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
||||||
|
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
||||||
|
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||||||
|
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
||||||
|
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
|
||||||
|
|
||||||
|
/* ---------------- state ---------------- */
|
||||||
|
const S = {
|
||||||
|
feeds:[],
|
||||||
|
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
||||||
|
// you were instead of jumping to the first feed alphabetically.
|
||||||
|
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
||||||
|
entries:[], total:0, offset:0,
|
||||||
|
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||||||
|
q:'', sel:null, me:null,
|
||||||
|
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||||||
|
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||||||
|
||{col:'published',dir:'desc'},
|
||||||
|
};
|
||||||
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
|
||||||
|
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
|
||||||
|
|
||||||
|
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
|
||||||
|
function splitEvery(m){
|
||||||
|
if(!m) return {n:1, u:'h'};
|
||||||
|
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
|
||||||
|
return {n:m, u:'m'};
|
||||||
|
}
|
||||||
|
function unitOptions(sel){
|
||||||
|
return UNITS.map(([v,l]) =>
|
||||||
|
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
|
||||||
|
}
|
||||||
|
function everyText(m){
|
||||||
|
if(!m) return '\u2014';
|
||||||
|
const {n,u} = splitEvery(m);
|
||||||
|
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
|
||||||
|
return n + ' ' + name + (u!=='m' && n!==1 ? 's' : '');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user