mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-15 00:37:33 +01:00
feat: add paid author and conference hosting with Stripe
This commit is contained in:
1 parent
b09ef0e5b0
commit
410863d253
54 files changed
+3263
-369
No files matched your search
@@ -138,3 +138,7 @@ diagram is encountered. These libraries use hashed URLs and load once per tab.
|
||||
The Docker build compiles the same bundles and copies them, the asset manifest,
|
||||
and document-worker assets into the runtime image. The Compose app serves these
|
||||
assets directly; no frontend development server is needed.
|
||||
|
||||
### Paid hosting
|
||||
|
||||
Optional Stripe billing supports monthly and annual author subscriptions, repository review passes, and prepaid conference packages. Billing is disabled by default. See the [setup and rollout guide](docs/paid-hosting.md) for prices, capacities, webhooks, migration behavior, and test-mode verification.
|
||||
@@ -0,0 +1,255 @@
|
||||
# Paid hosting
|
||||
|
||||
Billing is opt-in. `BILLING_ENABLED=false` preserves self-hosted behavior. This implementation uses Stripe Checkout and the customer portal. No card details are stored in the application.
|
||||
|
||||
## Offers
|
||||
|
||||
Configure four EUR prices in Stripe. The amounts below are suggested launch prices, not hard-coded charges:
|
||||
|
||||
- Author monthly: €5/month, recurring monthly.
|
||||
- Author annual: €49/year, recurring yearly.
|
||||
- Author review pass: €15 once, one existing repository for 180 days from checkout creation.
|
||||
- Conference review package: €199 once, up to 100 repositories during a fixed review window of at most 180 days.
|
||||
|
||||
Checkout uses the configured Stripe Price ID. The settings page displays its actual amount and cadence. Do not configure trials, metered prices, tiered prices, adjustable quantities, or subscription changes to unrelated products in the customer portal. New subscriptions need a paid invoice before access is granted. A failed renewal does not extend paid time. Cancellation at period end keeps access until that period ends; immediate cancellation ends new paid usage immediately.
|
||||
|
||||
Only repository hosting is covered. Gists and pull requests retain their existing behavior. Lab plans are not included.
|
||||
|
||||
Default capacities, all measured in source-file bytes:
|
||||
|
||||
- Free account: 5 repositories, 2 GiB total.
|
||||
- Author subscription: 20 repositories, 10 GiB total, and never-expiring repositories while subscribed.
|
||||
- Repository pass: 1 repository, 2 GiB.
|
||||
- Conference: 100 repositories, 50 GiB total.
|
||||
|
||||
These are admission limits, not a measure or guarantee of physical disk consumption. Original files, anonymized caches, metadata and backups need additional capacity. Review production usage before enabling billing. File-size safeguards and request throttles still apply to paid users. Reviewers do not need accounts or payment.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set these environment variables in your deployment's secret/configuration manager:
|
||||
|
||||
```text
|
||||
BILLING_ENABLED=true
|
||||
BILLING_ORIGIN=https://anonymous.example.org
|
||||
STRIPE_SECRET_KEY=<Stripe secret key>
|
||||
STRIPE_WEBHOOK_SECRET=<Stripe webhook signing secret>
|
||||
STRIPE_AUTHOR_MONTHLY_PRICE=<monthly recurring EUR price ID>
|
||||
STRIPE_AUTHOR_ANNUAL_PRICE=<annual recurring EUR price ID>
|
||||
STRIPE_AUTHOR_PASS_PRICE=<one-time EUR price ID>
|
||||
STRIPE_CONFERENCE_PRICE=<one-time EUR price ID>
|
||||
BILLING_AUTOMATIC_TAX=false
|
||||
```
|
||||
|
||||
Price IDs can be omitted to hide offers. Use test-mode keys and prices for staging. `BILLING_ORIGIN` must be the application's canonical HTTPS origin; HTTP is accepted only for localhost development. Redirects never use request-supplied hosts or return URLs. Enable and configure Stripe Tax before setting `BILLING_AUTOMATIC_TAX=true`; otherwise configure the appropriate tax treatment in Stripe before launch. Receipts, seller details and portal settings also need configuration in Stripe.
|
||||
|
||||
Capacity overrides are positive integers:
|
||||
|
||||
```text
|
||||
FREE_AUTHOR_REPOSITORIES=5
|
||||
FREE_AUTHOR_STORAGE=2147483648
|
||||
PAID_AUTHOR_REPOSITORIES=20
|
||||
PAID_AUTHOR_STORAGE=10737418240
|
||||
PAID_PASS_STORAGE=2147483648
|
||||
PAID_CONFERENCE_REPOSITORIES=100
|
||||
PAID_CONFERENCE_STORAGE=53687091200
|
||||
```
|
||||
|
||||
Changing these values changes the allowance of existing plans too. Keep existing Price IDs configured for as long as subscriptions using them remain active. Plan and conference review-window changes are intentionally locked in this first version.
|
||||
|
||||
Configure the Stripe customer portal for payment-method updates, invoice downloads and cancellation. Disable arbitrary subscription product switches. Configure a snapshot webhook at `/api/billing/webhook` using API version `2026-10-28.endive`, matching the installed Stripe SDK, for:
|
||||
|
||||
- `checkout.session.completed`
|
||||
- `checkout.session.async_payment_succeeded`
|
||||
- `customer.subscription.created`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- `customer.subscription.paused`
|
||||
- `customer.subscription.resumed`
|
||||
- `invoice.paid`
|
||||
- `invoice.payment_failed`
|
||||
- `charge.refunded`
|
||||
- `charge.dispute.created`
|
||||
|
||||
The webhook is mounted before JSON parsing so Stripe verifies the original request bytes. Failed durable receipt returns 500 for Stripe retries. Processing runs asynchronously from the stored event inbox. Duplicate deliveries do not extend passes or allocate duplicate grants. Subscription events retrieve current Stripe state and use a database revision check to prevent stale concurrent writes. Full refunds and disputes suspend the affected paid grant; partial refunds preserve access; reinstatement after a resolved dispute requires operator review.
|
||||
|
||||
## Lifecycle and migration
|
||||
|
||||
New paid conferences remain `pending_payment` until a verified checkout activates them. Collect billing addresses in Stripe. Historic conference plans remain editable and keep their existing status, but are no longer offered to new conferences. There is no automatic charge for historical estimated usage.
|
||||
|
||||
Existing repositories are not deleted or hidden on rollout or subscription expiry. New repositories are marked `billingManaged`; their file trees must fit an allowance before publication. A repository that fails this check remains available in its owner's dashboard for upgrading and retrying. Buy a pass by entering that repository's anonymized ID in Settings. Automatic refresh pauses for billing-managed repositories on the free allowance, including after paid access expires. Owners can still request a refresh within that allowance. Existing review links retain their configured expiration dates. This is not a guarantee of perpetual hosting.
|
||||
|
||||
Allowances are selected in order: paid conference, repository pass, author subscription, free account. MongoDB quota ledgers atomically reserve count and source-file bytes. They initially count existing repositories using stored size metadata. Reservations never shrink during refresh, so a concurrent or failed build cannot release capacity still in use. Confirmed removed repositories release reservations on the next admission. Expired repositories and orphan reservations remain reserved until operator reconciliation. This deliberately favors conservative capacity accounting over overselling. The account usage display uses reserved capacity once a ledger exists. A moved repository can retain conservative reservations in its previous scope until removal.
|
||||
|
||||
For billing-managed repositories, on-demand recovery and ZIP downloads from truncated trees are rejected with `repository_tree_incomplete`. This prevents serving unaccounted files beyond the indexed tree. A complete source tree is required; refresh after reducing repository size or excluded content upstream.
|
||||
|
||||
Billing records are in separate `billingaccounts`, `billinggrants`, `billingquotas` and `billinglocks` collections. They are not serialized on anonymous repository pages. Account deletion cancels recurring subscriptions and expires any known open checkout before deleting content. Minimal provider IDs and financial records remain for reconciliation. Do not disable billing configuration while active subscriptions still need webhook processing or cancellation.
|
||||
|
||||
## Verification and operation
|
||||
|
||||
Run focused policy and MongoDB tests against a disposable database:
|
||||
|
||||
```sh
|
||||
npx mocha test/billing.test.js
|
||||
BILLING_TEST_MONGODB_URI=mongodb://127.0.0.1:27017/billing_test_paid npx mocha test/billing-integration.test.js
|
||||
npm run test:ui
|
||||
npm run build
|
||||
```
|
||||
|
||||
The integration suite deletes data in its test collections and requires a database name beginning with `billing_test_`. It covers concurrent admission, duplicate fulfillment, customer binding, refunds, subscription cancellation, failed renewal, stale event writes and conference activation. It uses a fake Stripe client and real MongoDB; it does not charge cards.
|
||||
|
||||
Before production rollout, run a Stripe test-mode purchase for each configured price, renewal with a test clock, failed renewal, cancellation, refund and webhook retry. Confirm currency, tax behavior, invoice details, capacities and backups. Inspect webhook delivery failures in Stripe. The in-app Refresh payment status action reconciles the latest checkout and known subscriptions; it is not a substitute for webhooks. Failed checkout requests preserve their idempotency key so retrying the same plan recovers the session.
|
||||
|
||||
Back up billing collections with the rest of MongoDB. Watch source bytes, physical storage, bandwidth and request rates separately. Stripe fees, taxes and refunds reduce gross revenue.
|
||||
|
||||
|
||||
### Coauthors and the free allowance
|
||||
|
||||
Only repositories owned by an account consume its allowance. Joining another author's repository does not consume a slot, and coauthors do not need subscriptions. A coauthor's own subscription does not increase the owner's allowance. Edits and refreshes use the owner's allowance, or the repository's paid conference/pass allowance. If that allowance is full, a collaborator's attempt to refresh or enlarge the repository can also be blocked. Existing review links remain available.
|
||||
|
||||
The free defaults are 5 repositories and 2 GiB, revised from the initial 10-repository proposal after the November 2, 2026 production audit. The audit counted ready, unexpired repositories; stored sizes were estimates rather than a fresh recount. Paid author subscriptions retain 20 repositories, 10 GiB and automatic refresh.
|
||||
|
||||
### Never-expiring repositories
|
||||
|
||||
Monthly and annual author subscribers can choose Never expire. This requires the repository owner's subscription even when a conference pays its storage allowance. A coauthor subscription or a one-time review pass does not unlock it.
|
||||
|
||||
Newly selected Never expire repositories store a billing deadline 90 days after the paid-through date. The five-minute maintenance job extends that deadline on renewal and changes overdue repositories to ordinary removal expiration. Cancellation does not shorten the paid-through period. Revocation does not extend a previously recorded deadline. Renewal during the grace period preserves the repository; after expiration, the author must renew and explicitly reactivate it.
|
||||
|
||||
Legacy never-expiring repositories have no billing deadline and remain unchanged. Disabling billing disables this reconciliation. Free users can choose a dated expiration within one year and extend it manually. Never expire does not exempt a repository from quotas, abuse controls, or service availability.
|
||||
|
||||
Real sandbox validation and repeatable API test instructions are recorded in [Stripe sandbox validation](stripe-sandbox-validation.md).
|
||||
|
||||
## Billing recovery and operation
|
||||
|
||||
`BILLING_CHECKOUT_ENABLED=false` stops new purchases while webhooks, reconciliation,
|
||||
the customer portal, and cancellation remain available. Keep `BILLING_ENABLED=true`
|
||||
and the existing Stripe price IDs configured while subscriptions exist.
|
||||
|
||||
Verified webhook routing references are durably inserted into `billingevents` before
|
||||
HTTP acknowledgement. A ten-second worker claims events with a five-minute lease,
|
||||
retries failures with exponential backoff, and marks an event failed after 12 failed
|
||||
processing attempts. Repeated delivery cannot create another inbox record. A worker
|
||||
that dies after fulfillment can replay the operation without allocating another grant.
|
||||
Completed envelopes are removed after 90 days; failed events remain for investigation.
|
||||
The inbox deliberately omits customer names, addresses, card data, and full payloads.
|
||||
Do not register Connect events on this application's platform webhook.
|
||||
|
||||
Every five minutes, a separate job selects up to ten due billing accounts. Each account
|
||||
is normally reconciled daily; failures retry after 15 minutes. Reconciliation paginates
|
||||
subscriptions, checkout sessions, and charges, including historical purchases absent
|
||||
from MongoDB. It also reconstructs confirmed paid periods from invoice lines when the
|
||||
latest invoice failed. This full customer-history scan favors recovery at the current
|
||||
scale. Measure Stripe request volume before shortening the daily interval.
|
||||
|
||||
Before the 90-day deadline triggers expiration, maintenance performs a fresh owner
|
||||
reconciliation. Provider or database failures defer that expiration and log an error.
|
||||
Legacy never-expiring repositories remain excluded. The recorded grace period does not
|
||||
restart during retries. Ordinary repository requests continue using local entitlements.
|
||||
|
||||
Checkout retries retain their key and exact creation parameters. Sessions have an
|
||||
explicit expiry. After 23 hours, unresolved requests are searched by purchase-attempt
|
||||
metadata before any retry. After 24 hours, an unmatched attempt can be replaced only if
|
||||
its immutable request contains an expired session deadline. Older records without that
|
||||
proof require operator review. Uncertain customer creation also stops after 23 hours;
|
||||
customer discovery can recover the mapping without creating another customer.
|
||||
|
||||
Partial refunds preserve access. Full refunds and disputes suspend the affected grant.
|
||||
`billingrevocations` records the payment decision even if its grant does not exist yet.
|
||||
A dispute after a reviewed refund requires review again. Reinstatement is an explicit operator
|
||||
decision; it does not create paid time or restart a subscription. Do not grant a refund
|
||||
merely to cancel renewal.
|
||||
|
||||
The existing application error log receives billing failures and five-minute health
|
||||
warnings for failed events, old pending events, unresolved checkout attempts, and failed
|
||||
reconciliation. These warnings also appear in the existing admin error viewer. External
|
||||
email or paging delivery still requires deployment monitoring configuration.
|
||||
|
||||
### Operator commands
|
||||
|
||||
Run with the application's normal server configuration and database access. Inspection
|
||||
is read-only; mutation commands require `BILLING_OPERATOR` and an audit reason. They
|
||||
write an intent record to `billingaudits` before performing work. Inspect resulting
|
||||
state after a command failure; an audit intent alone does not prove completion.
|
||||
|
||||
```sh
|
||||
node -r ts-node/register src/scripts/billing-operations.ts health
|
||||
node -r ts-node/register src/scripts/billing-operations.ts inspect OWNER_ID
|
||||
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts reconcile OWNER_ID 'Investigating missing paid access'
|
||||
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts replay test:platform:evt_ID 'Retry after correcting configuration'
|
||||
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts drain all 'Process queued recovery work'
|
||||
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts discover all 'Recover customer mappings after restore'
|
||||
BILLING_OPERATOR=operator-name node -r ts-node/register src/scripts/billing-operations.ts reinstate pi_ID 'Dispute resolved; access reinstatement approved'
|
||||
```
|
||||
|
||||
Discovery binds only customers tagged for this application to existing users. Conflicting
|
||||
customer mappings require investigation. `reinstate` reviews a payment reversal and
|
||||
keeps a subscription suspended if other unreviewed payment reversals remain. Reconcile
|
||||
the owner afterward. Restrict shell access to authorized operators; these are not public
|
||||
HTTP endpoints.
|
||||
|
||||
### Restore and launch checks
|
||||
|
||||
Back up `billingaccounts`, `billinggrants`, `billingquotas`, `billinglocks`, `billingevents`,
|
||||
`billingrevocations`, and `billingaudits` with repository data. Test a restore into an
|
||||
isolated environment with sandbox credentials. Keep destructive repository maintenance
|
||||
stopped during restore, discover customer mappings, reconcile restored owners, drain
|
||||
queued events, and inspect health before re-enabling normal scheduling. Never connect a
|
||||
restored test copy to live Stripe credentials.
|
||||
|
||||
Before launch, configure seller details, live prices, tax treatment, invoice information,
|
||||
portal restrictions, production email delivery, backup retention, and external alerts.
|
||||
These are deployment/business settings, not changes made by this implementation. Grace
|
||||
period email reminders are not currently sent by the app.
|
||||
|
||||
The recovery suite uses a disposable MongoDB database and simulated Stripe failures:
|
||||
|
||||
```sh
|
||||
BILLING_TEST_MONGODB_URI=mongodb://127.0.0.1:27029/billing_test_recovery npx mocha test/billing.test.js test/billing-integration.test.js test/billing-reliability.test.js
|
||||
```
|
||||
|
||||
## Payment within the repository workflow
|
||||
|
||||
Repository hosting choices now appear in the anonymization form when capacity or a
|
||||
new Never expire selection requires them. Free dated submissions and coauthor work
|
||||
keep their existing flow. Conference admission and final capacity are still checked
|
||||
server-side; an account usage display cannot authorize or permanently block admission.
|
||||
Existing repository owners can also buy a repository-scoped pass from that form.
|
||||
|
||||
Before leaving for hosted Stripe Checkout, the app flushes pending field edits and
|
||||
saves a private task draft. `/api/billing/draft` requires authentication and the billing
|
||||
CSRF token. Drafts retain only the form fields needed to resume, are limited to 64 KiB,
|
||||
and expire after seven days. Retrieval checks the owner and expiry even before MongoDB's
|
||||
TTL sweep runs. Account deletion also removes these drafts.
|
||||
|
||||
Checkout returns to the draft's validated anonymization route, or the conference
|
||||
being purchased, rather than always returning to Settings. No request-supplied external
|
||||
return URL is accepted. An open or uncertain checkout attempt cannot be reused for a
|
||||
different task. Returning restores the draft, refreshes billing and capacity, and leaves
|
||||
submission to the author. The return token is consumed after restoration so later
|
||||
navigation cannot overwrite newer edits. Confirmation does not create a repository.
|
||||
|
||||
The payment form remains Stripe-hosted. Embedded Checkout is not required for this
|
||||
workflow and is not enabled by this change. Settings remain available for invoices
|
||||
and subscription management.
|
||||
|
||||
Validation: 40 billing tests with disposable MongoDB and 160 frontend tests passed.
|
||||
Additional HTTP checks cover draft CSRF, authentication, ownership and no-store headers.
|
||||
The built UI was checked at desktop and mobile widths using simulated payment responses;
|
||||
redaction edits survived the checkout return. These workflow changes were not charged
|
||||
through a fresh real Stripe sandbox payment or deployed to production.
|
||||
|
||||
### Current application layout
|
||||
|
||||
The anonymization page keeps the main configuration sections visible beside a live
|
||||
README preview. Source controls retain the existing GitHub connection selector,
|
||||
manage-access action and branch refresh button. Conference code is a visible optional
|
||||
field. Hosting choices appear within configuration, with saved drafts preserved through
|
||||
Stripe Checkout and return.
|
||||
|
||||
Settings separates anonymization defaults, hosting and billing, and account actions.
|
||||
The header Upgrade action sits beside the user menu and opens the plan comparison.
|
||||
Active subscribers see subscription management instead of another purchase offer.
|
||||
The donation entry remains available.
|
||||
|
||||
Local browser checks used simulated payment responses for the final layout. The real
|
||||
Stripe sandbox checks and their limitations are documented in
|
||||
[stripe-sandbox-validation.md](stripe-sandbox-validation.md).
|
||||
@@ -0,0 +1,104 @@
|
||||
# Stripe sandbox validation
|
||||
|
||||
Validated November 2, 2026 using a real Stripe sandbox, disposable local MongoDB, Stripe-hosted Checkout and the customer portal. The Stripe CLI forwarded real signed events to the application's raw webhook route.
|
||||
|
||||
## Results
|
||||
|
||||
The repeatable API suite passed 16 scenarios:
|
||||
|
||||
- Checkout creation, reuse, unpaid-access denial and session expiry for all four plans.
|
||||
- Successful monthly and annual subscription payments and author entitlements.
|
||||
- Duplicate subscription prevention for both recurring plans.
|
||||
- Scheduled cancellation preserves the paid period.
|
||||
- Immediate cancellation removes paid capacity; never-expiring repositories retain the recorded grace deadline and enter expiration after it.
|
||||
- A declined initial payment grants no subscription access.
|
||||
- Subscription refunds revoke access. Repeated signed events are idempotent; altered payloads fail signature verification; later subscription sync does not undo revocation.
|
||||
- A Stripe test clock advances a monthly subscription through successful renewal, failed renewal and recovery. Only paid invoices advance paid-through time.
|
||||
- Account deletion cancels its subscription.
|
||||
|
||||
Interactive checks also passed:
|
||||
|
||||
- A repository pass purchased through hosted Checkout activated through a real webhook.
|
||||
- A conference purchase changed pending payment to ready through a real webhook.
|
||||
- Monthly Checkout displayed an insufficient-funds decline without granting access, then activated after retry with a successful test card.
|
||||
- Annual Checkout granted no access after failed 3-D Secure authentication and activated after successful authentication.
|
||||
- Refunding the repository pass revoked its grant through a real webhook.
|
||||
- The customer portal showed billing details and invoices and scheduled cancellation at the paid-period boundary.
|
||||
- A disputed sandbox subscription payment triggered a real dispute webhook, revoked access and remained revoked after resync.
|
||||
|
||||
The local billing regression suite passed 23 tests, including quota concurrency, coauthor accounting, authorization/CSRF, grace periods and signature checks. TypeScript and targeted lint checks passed.
|
||||
|
||||
## Fixes found by real Stripe testing
|
||||
|
||||
1. Open subscription Checkout sessions do not yet have a subscription ID. Fulfillment now ignores unfinished sessions instead of reporting an invalid purchase.
|
||||
2. The customer portal can schedule cancellation using cancel_at while cancel_at_period_end remains false. The app now recognizes either representation when displaying cancelled renewal.
|
||||
|
||||
## Repeating the API checks
|
||||
|
||||
Create a private JSON file outside the repository with a secret field containing a sandbox secret key. The script rejects live keys and verifies Stripe reports sandbox mode before creating fixtures.
|
||||
|
||||
Run against a disposable MongoDB database whose name begins with billing_test_:
|
||||
|
||||
```sh
|
||||
STRIPE_SANDBOX_CREDENTIALS=/absolute/path/to/private-credentials.json \
|
||||
BILLING_TEST_MONGODB_URI=mongodb://127.0.0.1:27029/billing_test_stripe \
|
||||
node scripts/test-stripe-sandbox.js
|
||||
```
|
||||
|
||||
The script creates tagged test customers, one product and four test prices. It deletes its customers and test clocks and deactivates its prices/product afterward. Stripe retains transaction history. It does not change existing prices or production settings. Test price amounts are fixtures, not a published price commitment.
|
||||
|
||||
## Limits of this validation
|
||||
|
||||
- This covers the implemented card-payment flows, not every Stripe payment method or account configuration.
|
||||
- Automatic tax was disabled. Tax registration, live tax calculation, live settlement, seller details and production email delivery were not tested.
|
||||
- The test clock changes Stripe time, not the application's wall clock. Failed-renewal tests verify paid-through does not advance; local tests exercise access expiry and the 90-day boundary.
|
||||
- Hosted Checkout, portal and live CLI-forwarded webhook checks were interactive. The repeatable API suite uses real Stripe responses with locally generated webhook signatures for its HTTP replay test.
|
||||
- No production deployment or live payment occurred. Sandbox customers and active subscriptions from this run were cleaned up; products and prices were archived.
|
||||
|
||||
## Reliability follow-up
|
||||
|
||||
The repeatable 16-scenario sandbox suite passed again after introducing durable webhook
|
||||
receipt, asynchronous processing, checkout expiry, and provider reconciliation. The
|
||||
HTTP refund replay check now explicitly drains the inbox before asserting entitlements.
|
||||
No live payments or production deployment were performed.
|
||||
|
||||
The local billing suites now include crash recovery, concurrent event receipt, retry
|
||||
exhaustion, privacy of stored envelopes, reconstruction after missing local grants,
|
||||
missed renewal recovery, refund-before-fulfillment ordering, partial refunds, uncertain
|
||||
checkout recovery, purchase shutdown, and expiration deferral during provider outages.
|
||||
These are deterministic failure simulations against disposable MongoDB, not a claim
|
||||
that production backup infrastructure or external alert delivery has been tested.
|
||||
|
||||
Final follow-up checks passed: 38 local billing tests, 155 frontend regression/UI tests,
|
||||
TypeScript compilation, the production build, and targeted ESLint checks. The 16-scenario
|
||||
real Stripe sandbox suite also passed with historical invoice recovery enabled.
|
||||
|
||||
## Full application UI check — November 2, 2026
|
||||
|
||||
Used the built application in the collaborative browser, production billing routes,
|
||||
an isolated MongoDB database and real Stripe sandbox Checkout and customer portal.
|
||||
GitHub login was replaced with a test-only session fixture outside the repository.
|
||||
|
||||
| UI flow | Observed result |
|
||||
| --- | --- |
|
||||
| Free account | Five repositories and 2 GiB displayed. |
|
||||
| Monthly subscription | Insufficient-funds decline granted no access; successful retry activated 20 repositories and 10 GiB. |
|
||||
| Duplicate subscription | App rejected another subscription and displayed the explanation. |
|
||||
| Customer portal | Billing details and invoices loaded; cancellation stopped renewal while preserving paid access. |
|
||||
| Annual subscription | Failed 3-D Secure granted no access; successful authentication activated annual access. |
|
||||
| Delayed webhook processing | With the worker paused, the checkout return recovered annual access from Stripe. Both billing and quota panels reflected the upgrade. Queued events completed after resuming the worker. |
|
||||
| Abandoned repository pass | Returning without payment granted no access. Reopening reused the same checkout session. |
|
||||
| Paid repository pass | Successful checkout created a repository-scoped grant. |
|
||||
| Refunded pass | A sandbox API refund generated real webhooks; refreshing in the UI showed revoked access. Refund initiation was an operator action, not an app UI feature. |
|
||||
| Conference package | Checkout from the pending conference activated the package; the conference page showed Ready. |
|
||||
|
||||
Two UI issues were fixed during this run: checkout reconciliation now refreshes the
|
||||
quota panel, and revoked purchases no longer display a future paid-access date.
|
||||
The quota issue has a regression test. Four targeted UI tests and the UI build passed;
|
||||
both fixes were also checked in the real browser. No failed or pending received
|
||||
webhook events remained at the final pre-cleanup inspection.
|
||||
|
||||
This run does not cover GitHub OAuth, repository ingestion, production deployment,
|
||||
tax, email delivery, or the passage of the 90-day grace period. Those should not be
|
||||
inferred from the payment UI results. Temporary customers/subscriptions were deleted,
|
||||
prices and product archived, and the disposable local services removed after testing.
|
||||
Generated
+18
@@ -41,6 +41,7 @@
|
||||
"re2js": "^2.8.6",
|
||||
"redis": "^5.12.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"stripe": "^23.1.0",
|
||||
"ts-custom-error": "^3.3.1",
|
||||
"unzip-stream": "^0.3.4"
|
||||
},
|
||||
@@ -10865,6 +10866,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "23.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-23.1.0.tgz",
|
||||
"integrity": "sha512-fVUfMSfTPoukfoV//MUKAx2+YxyKmYaYtnAL9VkfnHfaPNQylFdWZMD+zgUFerI8rjbaiER8Vzj2XEUQ2AhAsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
||||
+3
-2
@@ -73,6 +73,7 @@
|
||||
"re2js": "^2.8.6",
|
||||
"redis": "^5.12.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"stripe": "^23.1.0",
|
||||
"ts-custom-error": "^3.3.1",
|
||||
"unzip-stream": "^0.3.4"
|
||||
},
|
||||
@@ -106,9 +107,9 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.71.1",
|
||||
"vinyl-fs": "^4.0.2",
|
||||
"vue": "^3.5.43",
|
||||
"vue-router": "^4.6.4",
|
||||
"vinyl-fs": "^4.0.2"
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.15.0 || ^24.0.0 || >=26.0.0"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"core.min.js": "core.3b07189932.min.js",
|
||||
"vendor.min.js": "vendor.9d3752006f.min.js",
|
||||
"vendor.min.js": "vendor.a8c61c4ffb.min.js",
|
||||
"mermaid.min.js": "mermaid.f848a72d16.min.js",
|
||||
"all.min.css": "all.d1feb1dfc4.min.css",
|
||||
"all.min.css": "all.1d7ab8bc01.min.css",
|
||||
"markdown.min.js": "markdown.44f2e93820.min.js",
|
||||
"pdf.min.js": "pdf.4d9cc6ff49.min.js",
|
||||
"notebook.min.js": "notebook.c3144c18ae.min.js",
|
||||
|
||||
Vendored
+1
-1
@@ -6,4 +6,4 @@
|
||||
*/:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]){color:inherit;text-decoration:none}a:not([href]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unsLine truncated
|
||||
* Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
*/.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:solid .08em #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{-webkit-transform:scale(1,-1);transform:scale(1,-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1,-1);transform:scale(-1,-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{contentLine truncated
|
||||
*/.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:solid .08em #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{-webkit-transform:scale(1,-1);transform:scale(1,-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1,-1);transform:scale(-1,-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{contentLine truncated
|
||||
@@ -7773,3 +7773,299 @@ input[type="date"], select { color-scheme: light; }
|
||||
.q-card { min-width: 130px; }
|
||||
.q-header-actions { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
|
||||
/* Billing follows the settings page's typography and section rules. */
|
||||
.billing-plan { padding: 1.5rem 0; border-bottom: 1px solid var(--paper-border, #d8d4cd); }
|
||||
.billing-plan h3 { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; justify-content: space-between; }
|
||||
.billing-price { font-variant-numeric: tabular-nums; font-weight: normal; }
|
||||
.billing-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1rem; }
|
||||
#settings-billing p { max-width: 72ch; }
|
||||
|
||||
/* Task-local hosting choices inherit the repository form's paper palette. */
|
||||
.repository-hosting { border-top: 1px solid var(--border-color); margin-top: 24px; }
|
||||
.repository-hosting h2 { font-size: 1.5rem; }
|
||||
.hosting-choices { border: 0; padding: 0; margin: 20px 0; min-width: 0; }
|
||||
.hosting-choices legend { font-size: 1rem; font-weight: 600; }
|
||||
.hosting-choice { display: flex; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid var(--border-color); background: var(--paper-card); cursor: pointer; }
|
||||
.hosting-choice + .hosting-choice { border-top: 0; }
|
||||
.hosting-choice input { margin-top: 5px; accent-color: var(--accent); }
|
||||
.hosting-choice strong, .hosting-choice small { display: block; }
|
||||
.hosting-choice small { margin-top: 6px; color: var(--ink-muted); }
|
||||
.hosting-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin: 20px 0; }
|
||||
.repository-pass { margin: 20px 0; }
|
||||
.repository-pass summary { cursor: pointer; color: var(--link-color); }
|
||||
.repository-pass p { margin-top: 12px; }
|
||||
|
||||
/* Approved task-based payment flow: review content and a persistent summary. */
|
||||
.task-review-active.anonymize-page { height: auto !important; min-height: 100%; overflow: visible; }
|
||||
.task-review-active .anonymize-workspace { flex: 0 0 auto; overflow: visible; }
|
||||
.repository-task { width: 100%; max-width: 1020px; margin: 0 auto; padding: 32px 24px 42px; color: var(--color); font-size: 15px; line-height: 1.55; }
|
||||
.repository-task .paper-crumbs { margin-bottom: 24px; }
|
||||
.repository-task h1 { font: normal 40px/1.15 'Iowan Old Style', Georgia, serif; margin: 8px 0 10px; }
|
||||
.repository-task h2 { font: normal 27px/1.25 'Iowan Old Style', Georgia, serif; margin: 0 0 12px; }
|
||||
.repository-task h3 { font: 600 17px/1.5 var(--font-sans); margin: 0 0 10px; }
|
||||
.repository-task p { margin: 0 0 16px; }
|
||||
.task-lede, .task-muted { color: var(--ink-muted); }
|
||||
.task-trail { list-style: none; display: flex; flex-wrap: wrap; gap: 24px; margin: 30px 0; padding: 18px 0; border-bottom: 1px solid var(--border-color); color: var(--ink-muted); font-size: 13px; }
|
||||
.task-trail [aria-current] { color: var(--color); font-weight: 600; }
|
||||
.task-trail button { color: inherit; padding: 0; border: 0; background: none; font: inherit; cursor: pointer; }
|
||||
.task-columns { display: grid; grid-template-columns: minmax(0,1.6fr) minmax(245px,1fr); gap: 34px; align-items: start; }
|
||||
.task-section-title { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
|
||||
.task-text-button { border: 0; padding: 7px 0; background: transparent; color: var(--link-color); cursor: pointer; font: inherit; text-align: left; }
|
||||
.task-text-button:focus-visible, .task-trail button:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||
.task-summary { background: var(--paper-bg-alt); padding: 24px; border-radius: 5px; }
|
||||
.task-summary dt { margin-top: 18px; font-size: 12px; font-weight: 400; color: var(--ink-muted); }
|
||||
.task-summary dd { margin: 3px 0; overflow-wrap: anywhere; }
|
||||
.task-summary h3, .task-redactions { overflow-wrap: anywhere; }
|
||||
.task-summary dl { margin-bottom: 0; }
|
||||
.task-summary-foot { border-top: 1px solid var(--border-color); margin-top: 28px; padding-top: 24px; }
|
||||
.task-summary-foot p { color: var(--ink-muted); margin: 0; }
|
||||
.task-hosting { border-top: 1px solid var(--border-color); margin-top: 22px; padding-top: 20px; }
|
||||
.repository-task .hosting-choices { margin: 18px 0; }
|
||||
.repository-task .hosting-choice { padding: 18px; gap: 13px; font-weight: 400; margin: 0; }
|
||||
.repository-task .hosting-choice:first-of-type { border-radius: 6px 6px 0 0; }
|
||||
.repository-task .hosting-choice:last-of-type { border-radius: 0 0 6px 6px; }
|
||||
.repository-task .hosting-choice small { margin-top: 0; font-size: 13px; line-height: 1.55; }
|
||||
.task-terms { font-size: 13px; color: var(--ink-soft); }
|
||||
.task-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; margin: 24px 0 16px; }
|
||||
.repository-task .btn { padding: 10px 16px; border-radius: 5px; font-size: 15px; }
|
||||
.task-status { background: var(--accent-soft); padding: 16px 20px; margin: 20px 0; }
|
||||
.task-status p:last-child { margin: 0; }
|
||||
@media(max-width:650px) {
|
||||
.repository-task { padding: 24px 18px; }
|
||||
.repository-task h1 { font-size: 32px; }
|
||||
.task-columns { display: block; }
|
||||
.task-summary { margin-top: 28px; }
|
||||
.task-trail { gap: 15px; font-size: 12px; }
|
||||
}
|
||||
.repository-task { scroll-margin-top: 70px; }
|
||||
.task-columns { align-items: stretch; }
|
||||
.repository-task .btn-ink { background: var(--color); border-color: var(--color); color: var(--canvas-bg-color); box-shadow: none; }
|
||||
.repository-task .btn-ink:hover { background: var(--ink-soft); border-color: var(--ink-soft); }
|
||||
|
||||
.task-field { margin-bottom: 24px; }
|
||||
.task-field > label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||||
.task-field input, .task-field select, .task-field textarea { width: 100%; padding: 12px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--paper-card); color: var(--color); font: inherit; }
|
||||
.task-field textarea { resize: vertical; }
|
||||
.task-field small { display: block; margin-top: 8px; font-size: 13px; color: var(--ink-muted); }
|
||||
.task-field :is(input, select, textarea):focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||
|
||||
.task-paybox { background: var(--paper-card); border: 1px solid var(--border-color); border-radius: 5px; padding: 22px; margin-top: 20px; }
|
||||
|
||||
.task-ready { border-left: 3px solid var(--success, #2f7a44); padding: 16px 20px; background: var(--paper-bg-alt); margin-bottom: 22px; }
|
||||
.repository-task.status-page .paper-progress { margin-top: 24px; }
|
||||
|
||||
.task-date-options summary { cursor: pointer; color: var(--link-color); font-size: 13px; }
|
||||
.task-date-options[open] summary { margin-bottom: 12px; }
|
||||
|
||||
.task-result { padding: 18px; background: var(--paper-bg-alt); overflow-wrap: anywhere; }
|
||||
.task-result .detail-label { font-size: 12px; color: var(--ink-muted); margin-bottom: 6px; }
|
||||
|
||||
/* Step one keeps redaction feedback beside the fields being edited. */
|
||||
.task-configure-columns { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
|
||||
.task-live-preview { min-width: 0; border: 1px solid var(--border-color); border-radius: 5px; background: var(--paper-card); }
|
||||
.task-preview-head { padding: 20px 24px; border-bottom: 1px solid var(--border-color); }
|
||||
.task-preview-head h2 { font-size: 23px; margin-bottom: 6px; }
|
||||
.task-preview-head p { margin: 0; font-size: 13px; }
|
||||
.task-preview-message, .task-preview-content { padding: 24px; }
|
||||
.task-preview-content { overflow: auto; max-height: 650px; overflow-wrap: anywhere; }
|
||||
.repository-task .task-preview-content h1 { font: 600 28px/1.3 var(--font-sans); }
|
||||
.repository-task .task-preview-content h2 { font: 600 22px/1.3 var(--font-sans); }
|
||||
@media(max-width:650px) {
|
||||
.task-live-preview { margin-top: 28px; }
|
||||
.task-preview-content { max-height: none; }
|
||||
}
|
||||
|
||||
/* Anonymization workspace: visible configuration and contextual hosting (mock v8). */
|
||||
.anonymize-page .anonymize-topbar { padding: 14px 22px; background: var(--canvas-bg-color); }
|
||||
.anonymize-page .anonymize-topbar-inner { max-width: none; }
|
||||
.anonymize-page .anonymize-topbar-head { justify-content: space-between; }
|
||||
.anonymize-page .anonymize-topbar-title { font: 600 19px/1.25 var(--font-sans); margin: 0; }
|
||||
.anonymize-page .anonymize-split { display: grid; grid-template-columns: minmax(320px,42%) minmax(0,1fr); overflow: hidden; }
|
||||
.anonymize-page .anonymize-form-col { width: auto; min-height: 0; overflow: hidden !important; background: var(--paper-card); }
|
||||
.anonymize-page .anonymize-form-col .paper-settings-main { display: flex; flex-direction: column; height: 100%; min-height: 0; max-width: none; padding: 0; }
|
||||
.anonymize-fields { flex: 1; min-height: 0; overflow: auto; position: relative; scrollbar-width: thin; }
|
||||
.anonymize-page .paper-settings-main .paper-settings-section { border: 0; border-top: 1px solid var(--border-color); border-radius: 0; padding: 18px 20px 20px; margin: 0; box-shadow: none; }
|
||||
.anonymize-page .paper-settings-main .paper-settings-section:first-child { border-top: 0; padding-top: 18px; }
|
||||
.anonymize-page .paper-section-eyebrow { font: 600 15px/1.4 var(--font-sans); margin: 0 0 16px; letter-spacing: 0; }
|
||||
.anonymize-page .paper-field-label { font: 500 13px/1.5 var(--font-sans); margin-bottom: 8px; }
|
||||
.anonymize-page .form-group { margin-bottom: 16px; }
|
||||
.anonymize-page .form-group:last-child { margin-bottom: 0; }
|
||||
.anonymize-page .form-control { font-size: 14px; min-height: 40px; padding: 9px 10px; border-radius: 4px; }
|
||||
.anonymize-page textarea.form-control { line-height: 1.6; }
|
||||
.anonymize-page .form-grid-2 { gap: 12px; }
|
||||
.anonymize-page .form-check { padding-top: 10px; padding-bottom: 10px; font-size: 14px; }
|
||||
.anonymize-page .form-check-label { font-size: 14px; line-height: 1.5; }
|
||||
.anonymize-page .form-text { font-size: 12px; line-height: 1.6; }
|
||||
.anonymize-page .repo-access-details { margin: 12px 0 16px; }
|
||||
.anonymize-page .repo-access-note { font-size: 11px; }
|
||||
.anonymize-page .anonymize-submit-bar { flex: none; position: static; margin: 0; padding: 16px 20px; background: var(--paper-card); border-top: 1px solid var(--border-color); }
|
||||
.anonymize-page .anonymize-submit-bar .btn { width: 100%; font-size: 14px; padding: 10px 12px; min-height: 43px; }
|
||||
.checkout-return-note { font-size: 11px; line-height: 1.5; color: var(--ink-muted); margin: 8px 0 0; text-align: center; }
|
||||
.anonymize-fields > .alert, .anonymize-fields > .draft-status, .anonymize-fields > .anonymize-review-note { margin: 12px 20px; }
|
||||
.anonymize-page .anonymize-preview-col { min-height: 0; padding: 20px; position: relative; background: var(--canvas-bg-color); }
|
||||
.anonymize-page .anonymize-preview-head { border: 0; padding: 0; min-height: 32px; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.anonymize-preview-head strong, .anonymize-preview-head button { font-size: 13px; }
|
||||
.anonymize-page .anonymize-preview-body { padding: 24px; border-radius: 6px; min-height: 480px; overflow-wrap: anywhere; font: 14px/1.7 var(--font-sans); }
|
||||
.anonymize-page .anonymize-preview-body h1 { font-size: 27px; line-height: 1.2; margin: 0 0 20px; padding-bottom: 14px; }
|
||||
.anonymize-page .anonymize-preview-body h2 { font-size: 18px; margin: 28px 0 12px; }
|
||||
.anonymize-page .anonymize-preview-body pre { font-size: 12px; padding: 15px; }
|
||||
.anonymize-page.preview-expanded .anonymize-split { grid-template-columns: 1fr; }
|
||||
.anonymize-page.preview-expanded .anonymize-form-col { display: none; }
|
||||
.hosting-usage { font-size: 12px; color: var(--ink-muted); margin: 0 0 4px; }
|
||||
.expiration-choices { border: 0; padding: 0; margin: 0 0 16px; min-width: 0; }
|
||||
.expiration-choice { display: flex; align-items: flex-start; gap: 10px; padding: 12px 0; margin: 0; cursor: pointer; font-size: 14px; font-weight: 500; line-height: 1.5; }
|
||||
.expiration-choice input { width: 16px; height: 16px; margin: 4px 0 0; flex: none; accent-color: var(--accent); }
|
||||
.expiration-choice small { display: block; font-size: 12px; font-weight: 400; color: var(--ink-muted); margin-top: 2px; }
|
||||
.dated-options { padding: 2px 0 14px 26px; }
|
||||
.hosting-property, .hosting-date { display: grid; grid-template-columns: 48px minmax(0,1fr); gap: 8px; align-items: center; margin-bottom: 10px; }
|
||||
.hosting-property { margin-bottom: 0; }
|
||||
.hosting-property label, .hosting-date .paper-field-label { font-size: 12px; font-weight: 400; margin: 0; }
|
||||
.hosting-date .invalid-feedback { grid-column: 1/-1; }
|
||||
.anonymize-page .conference-field { border-top: 1px solid var(--border-color); padding: 16px 0; margin: 0; }
|
||||
.subscription-choice { padding: 16px; background: var(--paper-bg-alt); border-radius: 6px; margin-top: 4px; }
|
||||
.subscription-choice h3 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
|
||||
.billing-cycle { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; border: 0; padding: 0; margin: 0 0 12px; min-width: 0; }
|
||||
.cycle-option { display: flex; align-items: center; gap: 7px; padding: 10px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--paper-card); font-size: 12px; cursor: pointer; margin: 0; }
|
||||
.cycle-option.selected { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.cycle-option input { accent-color: var(--accent); margin: 0; flex: none; }
|
||||
.subscription-choice p { font-size: 12px; line-height: 1.5; margin: 0 0 8px; }
|
||||
.subscription-choice p:last-child { margin: 0; }
|
||||
.hosting-terms { font-size: 12px; line-height: 1.5; color: var(--ink-muted); }
|
||||
.capacity-reason { font-size: 13px; }
|
||||
.capacity-options, .repository-pass { margin-top: 12px; font-size: 12px; }
|
||||
.capacity-options summary, .repository-pass summary { cursor: pointer; }
|
||||
.capacity-options p, .repository-pass p { margin: 8px 0; }
|
||||
.capacity-options .task-text-button { display: block; }
|
||||
@media(max-width:767px) {
|
||||
.anonymize-page .anonymize-split { display: block; overflow: visible; }
|
||||
.anonymize-page .anonymize-form-col .paper-settings-main { height: auto; }
|
||||
.anonymize-fields { overflow: visible; }
|
||||
.anonymize-page .paper-settings-main .paper-settings-section { padding: 18px 16px 20px; }
|
||||
.anonymize-page .anonymize-topbar { padding: 14px 16px; }
|
||||
.anonymize-page .anonymize-preview-col { padding: 16px; }
|
||||
.anonymize-page .anonymize-preview-body { padding: 20px; }
|
||||
.anonymize-page .anonymize-submit-bar { padding: 16px; }
|
||||
.anonymize-page .preview-expand { display: none; }
|
||||
.anonymize-page .anonymize-mobile-tabs { padding: 0; gap: 0; background: var(--paper-card); }
|
||||
.anonymize-mobile-tabs button { border-radius: 0; padding: 12px; min-height: 44px; }
|
||||
.anonymize-mobile-tabs button[aria-selected=true] { background: none; box-shadow: inset 0 -2px var(--accent); }
|
||||
.cycle-option { min-height: 44px; }
|
||||
}
|
||||
/* Match the mock's typography and document rendering without changing the app shell. */
|
||||
.anonymize-page { --font-sans: system-ui, -apple-system, sans-serif; font-family: var(--font-sans); }
|
||||
body:not(.dark-mode) .anonymize-page { --paper-card: #fff; --canvas-bg-color: #faf9f6; --paper-bg-alt: #f5f4f0; --border-color: #dedbd4; --color: #242320; --ink-muted: #68655e; }
|
||||
.anonymize-page .repo-access-controls .btn { font-size: 12px; padding: 8px 12px; min-height: 36px; }
|
||||
.anonymize-page .form-check-label { font-weight: 400; }
|
||||
.anonymize-page .form-check { padding-left: 26px; }
|
||||
.anonymize-page .form-check input { width: 16px; height: 16px; margin-left: -26px; accent-color: var(--accent); }
|
||||
.anonymize-page .form-check small { margin-top: 6px; }
|
||||
.anonymize-page .btn.btn-icon.refresh-branches { display: inline-flex; align-items: center; justify-content: center; width: 40px; min-width: 40px; height: 100%; min-height: 40px; padding: 0; }
|
||||
@media (max-width: 767px) {
|
||||
.anonymize-page #branch, .anonymize-page #commit { min-height: 44px; }
|
||||
.anonymize-page .btn.btn-icon.refresh-branches { width: 44px; min-width: 44px; min-height: 44px; }
|
||||
}
|
||||
.anonymize-page .redaction-syntax { font-size: 12px; margin-top: 8px; }
|
||||
.redaction-syntax summary { cursor: pointer; color: var(--link-color); min-height: 28px; }
|
||||
.anonymize-page .anonymize-preview-body h2 { border: 0; padding: 0; font-weight: 700; }
|
||||
.anonymize-page .anonymize-preview-body h1 { font-weight: 700; }
|
||||
.anonymize-page .anonymize-preview-body pre { background: var(--paper-bg-alt); line-height: 1.7; }
|
||||
.anonymize-page .anonymize-preview-body pre code { font: 12px/1.7 ui-monospace, monospace; background: none; padding: 0; }
|
||||
|
||||
.anonymize-page .anonymize-preview-body { --main-bg-color: var(--paper-card); background: var(--paper-card); }
|
||||
.anonymize-page .anonymize-submit-bar .btn { font-weight: 600; }
|
||||
.anonymize-page .form-grid-2 .form-group { margin-bottom: 0; }
|
||||
.anonymize-page .form-check small { margin-left: -26px; }
|
||||
/* Reserve the widget's bottom-left space without moving or hiding donations. */
|
||||
@media (min-width: 992px) {
|
||||
body:has([class*="floatingchat-container-wrap"]) .anonymize-page .anonymize-submit-bar {
|
||||
padding-left: 228px;
|
||||
}
|
||||
}
|
||||
|
||||
/* User settings: one sheet, with labels aligned beside their controls. */
|
||||
.user-settings { max-width: 1060px; padding: 28px 26px 40px; }
|
||||
.user-settings .settings-title { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
|
||||
.user-settings .settings-title h1 { font: 650 26px/1.3 var(--font-sans); letter-spacing: -.025em; margin: 0; }
|
||||
.settings-title > span { color: var(--ink-muted); font-size: 12px; }
|
||||
.settings-nav { display: flex; gap: 28px; border-bottom: 1px solid var(--border-color); }
|
||||
.settings-nav button { border: 0; border-radius: 0; background: transparent; color: var(--ink-muted); padding: 12px 0; min-height: 44px; font: 13px/1.4 var(--font-sans); }
|
||||
.settings-nav button[aria-current=page] { color: var(--color); font-weight: 600; box-shadow: inset 0 -2px var(--accent); }
|
||||
.settings-nav button:hover { color: var(--accent); }
|
||||
.user-settings .settings-scope { color: var(--ink-muted); font-size: 12px; margin: 20px 0 4px; }
|
||||
.user-settings .settings-row { display: grid; grid-template-columns: 210px minmax(0,1fr); gap: 32px; padding: 22px 0; border-bottom: 1px solid var(--border-color); }
|
||||
.user-settings .settings-row > h2, .user-settings .settings-label { font-size: 13px; font-weight: 600; line-height: 1.5; margin: 8px 0 0; display: block; }
|
||||
.user-settings .settings-help { font-size: 12px; color: var(--ink-muted); margin: 7px 0 0; line-height: 1.6; }
|
||||
.user-settings .form-control { font-size: 14px; min-height: 40px; padding: 9px 10px; border-radius: 4px; background: var(--paper-card); }
|
||||
.user-settings textarea.form-control { line-height: 1.6; }
|
||||
.settings-syntax { font-size: 12px; margin-top: 8px; }
|
||||
.settings-syntax summary { cursor: pointer; color: var(--link-color); min-height: 26px; }
|
||||
.settings-syntax p { margin: 8px 0; }
|
||||
.settings-checks { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 18px; padding: 4px 0; margin-bottom: 12px; }
|
||||
.user-settings .settings-check { display: flex; gap: 9px; align-items: center; font-size: 13px; font-weight: 400; margin: 0; }
|
||||
.user-settings .settings-check .form-check-input { position: static; width: 16px; height: 16px; margin: 0; flex: none; accent-color: var(--accent); }
|
||||
.settings-save { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 18px 0 22px; }
|
||||
.settings-save-status { color: var(--ink-muted); font-size: 12px; }
|
||||
.settings-save #save { margin-left: auto; min-width: 125px; }
|
||||
.settings-save-error { color: var(--status-error); flex-basis: 100%; }
|
||||
.user-settings .settings-inline { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.user-settings .btn { font-size: 13px; min-height: 40px; padding: 9px 13px; }
|
||||
.settings-inline .btn { flex-shrink: 0; }
|
||||
.user-settings #settings-quota { margin: 22px 0 16px; }
|
||||
.user-settings .quota-row { gap: 20px; }
|
||||
.user-settings .quota-item { padding: 0; background: transparent; border: 0; }
|
||||
.user-settings .quota-label { font-size: 12px; }
|
||||
.user-settings .quota-value { font-size: 13px; margin: 6px 0; }
|
||||
.user-settings .quota-value strong { font-size: 14px; }
|
||||
.user-settings .quota-percent, .user-settings .quota-remaining { font-size: 11px; }
|
||||
.user-settings .settings-plan { background: var(--paper-card); border: 1px solid var(--border-color); border-radius: 6px; padding: 20px; margin: 20px 0 0; }
|
||||
.user-settings .settings-plan h3 { font-size: 15px; margin: 0 0 10px; }
|
||||
.user-settings .settings-plan > p { font-size: 13px; }
|
||||
.settings-cycles { display: flex; flex-wrap: wrap; gap: 10px; border: 0; padding: 0; margin: 16px 0; }
|
||||
.settings-cycles label { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-color); background: var(--paper-card); border-radius: 5px; padding: 12px; font-size: 13px; margin: 0; cursor: pointer; flex: 1; }
|
||||
.settings-cycles label.selected { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.settings-cycles input { accent-color: var(--accent); }
|
||||
.settings-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
|
||||
.settings-notice { background: var(--paper-bg-alt); border: 1px solid var(--border-color); border-radius: 5px; padding: 14px 16px; margin: 16px 0; font-size: 13px; }
|
||||
.settings-notice p { margin: 6px 0 12px; }
|
||||
.settings-text-button { border: 0; background: transparent; color: var(--link-color); padding: 8px 0; font-size: 12px; }
|
||||
.settings-pass { margin-top: 18px; }
|
||||
.settings-pass label { display: block; font-size: 13px; margin-bottom: 8px; }
|
||||
.settings-pass .btn { margin-top: 12px; }
|
||||
.settings-purchase { padding: 12px 0; border-bottom: 1px solid var(--border-color); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.settings-purchase:last-child { border: 0; }
|
||||
.settings-purchase > span { display: block; color: var(--ink-muted); margin-top: 4px; }
|
||||
.user-settings .paper-account-card { padding: 24px 0; border: 0; background: none; }
|
||||
.user-settings .paper-danger-zone { margin-top: 24px; }
|
||||
@media (max-width: 767px) {
|
||||
.user-settings { padding: 22px 18px 90px; }
|
||||
.user-settings .settings-row { grid-template-columns: 1fr; gap: 12px; padding: 20px 0; }
|
||||
.user-settings .settings-row > h2, .user-settings .settings-label { margin-top: 0; }
|
||||
.settings-nav { gap: 18px; justify-content: space-between; }
|
||||
.settings-nav button { text-align: left; font-size: 12px; max-width: 124px; }
|
||||
.user-settings .settings-check { font-size: 12px; min-height: 28px; }
|
||||
.settings-save { gap: 8px; }
|
||||
.settings-save-status { font-size: 11px; }
|
||||
.settings-save #save { min-width: 120px; }
|
||||
.user-settings .quota-row { grid-template-columns: 1fr; }
|
||||
.settings-cycles label { flex-basis: 100%; }
|
||||
}
|
||||
.user-settings .settings-save .draft-status { margin: 0; padding: 0; }
|
||||
.settings-delete-confirm { max-width: 480px; margin-top: 18px; }
|
||||
.settings-delete-confirm label { display: block; font-size: 13px; margin-bottom: 8px; }
|
||||
.user-settings .quota-usage { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.user-settings .quota-usage .quota-item + .quota-item { border: 0; padding: 0; }
|
||||
.user-settings .quota-percent, .user-settings .quota-remaining { display: none; }
|
||||
@media (max-width: 767px) { .user-settings .quota-usage { grid-template-columns: 1fr; } }
|
||||
.settings-header-actions { display: flex; align-items: center; gap: 16px; }
|
||||
.settings-header-actions > span { color: var(--ink-muted); font-size: 12px; }
|
||||
.user-settings .settings-upgrade { color: var(--accent); border-color: var(--accent); background: transparent; }
|
||||
.user-settings .settings-comparison { width: 100%; table-layout: fixed; margin: 16px 0 20px; font-size: 12px; }
|
||||
.settings-comparison th, .settings-comparison td { padding: 10px 8px; border-bottom: 1px solid var(--border-color); text-align: left; overflow-wrap: anywhere; vertical-align: top; }
|
||||
.settings-comparison th:first-child { width: 42%; padding-left: 0; }
|
||||
.settings-comparison tbody th { font-weight: 400; }
|
||||
.settings-comparison thead th { font-weight: 600; }
|
||||
@media(max-width: 580px) { .settings-header-actions > span { display: none; } .settings-header-actions { gap: 8px; } .user-settings .settings-comparison { font-size: 11px; } }
|
||||
.navbar .nav-link.nav-upgrade { border: 1px solid var(--accent); border-radius: 5px; color: var(--accent) !important; padding: 7px 12px !important; margin-right: 12px; font-size: 12px; line-height: 1.5; }
|
||||
.navbar .nav-link.nav-upgrade:hover { background: var(--accent-soft); }
|
||||
@media(max-width:991px) { .navbar .nav-link.nav-upgrade { display: inline-block; min-height: 44px; padding: 11px 14px !important; margin: 8px 0; } }
|
||||
@@ -140,7 +140,15 @@
|
||||
"github_grant_revocation_failed": "Unable to revoke the GitHub grant. Please retry.",
|
||||
"another_login_required": "Connect and verify another sign-in method before disconnecting OAuth.",
|
||||
"oauth_resources_remaining": "Some resources still require OAuth. Migrate or remove them before disconnecting.",
|
||||
"invalid_github_path": "The GitHub repository path is invalid."
|
||||
"invalid_github_path": "The GitHub repository path is invalid.",
|
||||
"repository_tree_incomplete": "The repository file tree is incomplete. Reduce its size and refresh before downloading.",
|
||||
"billing_unavailable": "Payments are not available. Please try again later.",
|
||||
"invalid_billing_signature": "The payment notification could not be verified.",
|
||||
"billing_webhook_failed": "The payment notification could not be processed.",
|
||||
"billing_customer_not_found": "No billing account was found.",
|
||||
"subscription_required_for_never_expiration": "Never-expiring repositories require the owner’s active monthly or annual author subscription. Choose a dated expiration or upgrade in Plans and billing.",
|
||||
"invalid_billing_expiration": "Choose an expiration date within the next year, or select Never expire with an author subscription.",
|
||||
"pending_payment": "Awaiting payment"
|
||||
},
|
||||
"WARNINGS": {
|
||||
"page_not_enabled_on_repo": "GitHub Pages is not enabled on this repository. Enable it in the repository's Settings → Pages on GitHub, then refresh.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="anonymize-page h-100" :class="{'mobile-preview-active': mobilePane === 'preview'}">
|
||||
<div class="anonymize-page h-100" :class="{'mobile-preview-active': mobilePane === 'preview', 'preview-expanded': previewExpanded}">
|
||||
<!-- ===== STATE 1: No URL — centered input ===== -->
|
||||
<div class="anonymize-landing" v-show="!workspaceOpen && !isUpdate">
|
||||
<div class="anonymize-landing-inner">
|
||||
@@ -34,7 +34,7 @@
|
||||
<a class="connection-link" href="/connections" v-if="isUpdate || detectedType === 'gist' || githubConnection === 'oauth'">{{ isUpdate ? 'Change connection' : 'Manage connections' }} →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-access-note"><span v-if="detectedType !== 'gist' && githubConnection !== 'oauth'">Select repositories on GitHub, then return here.</span><span><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-info"></use></svg> Selected content will be published, including content from private repositories.</span></div>
|
||||
<div class="repo-access-note"><span><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-info"></use></svg> Anonymized content is public, even from private repositories.</span></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,14 +43,10 @@
|
||||
<div class="anonymize-workspace" v-show="workspaceOpen || isUpdate">
|
||||
<header class="anonymize-topbar">
|
||||
<div class="anonymize-topbar-inner">
|
||||
<div class="paper-crumbs">
|
||||
<a href="/dashboard">My work</a> /
|
||||
<span class="here">{{ isUpdate ? 'Edit anonymization' : 'New anonymization' }}</span>
|
||||
</div>
|
||||
<div class="anonymize-topbar-head">
|
||||
<h1 class="paper-page-title anonymize-topbar-title">
|
||||
<span v-if="!isUpdate">New <em>anonymization</em></span>
|
||||
<span v-if="isUpdate">Edit <em>anonymization</em></span>
|
||||
<span v-if="!isUpdate">New anonymization</span>
|
||||
<span v-if="isUpdate">Edit anonymization</span>
|
||||
</h1>
|
||||
<span class="type-badge" v-show="detectedType" :class="{'type-repo': detectedType === 'repo', 'type-pr': detectedType === 'pr', 'type-gist': detectedType === 'gist'}">
|
||||
{{ detectedType === 'repo' ? 'Repo' : detectedType === 'pr' ? 'PR' : 'Gist' }}
|
||||
@@ -78,10 +74,10 @@
|
||||
<div class="anonymize-split">
|
||||
<!-- Form column (left) -->
|
||||
<div id="workspace-settings" :role="mobileWorkspace ? 'tabpanel' : null" :aria-labelledby="mobileWorkspace ? 'workspace-settings-tab' : null" class="anonymize-form-col overflow-auto">
|
||||
<form class="form needs-validation paper-settings-main" name="anonymize" novalidate v-form="viewState">
|
||||
<form class="form needs-validation paper-settings-main" name="anonymize" novalidate v-form="viewState"><div class="anonymize-fields">
|
||||
|
||||
<section class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Source</div>
|
||||
<h2 class="paper-section-eyebrow">Source & access</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="source-url-label-row">
|
||||
@@ -116,7 +112,7 @@
|
||||
<a class="connection-link" href="/connections" v-if="isUpdate || detectedType === 'gist' || githubConnection === 'oauth'">{{ isUpdate ? 'Change connection' : 'Manage connections' }} →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-access-note"><span v-if="detectedType !== 'gist' && githubConnection !== 'oauth'">Select repositories on GitHub, then return here.</span><span><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-info"></use></svg> Selected content will be published, including content from private repositories.</span></div>
|
||||
<div class="repo-access-note"><span><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-info"></use></svg> Anonymized content is public, even from private repositories.</span></div>
|
||||
</aside>
|
||||
|
||||
<aside class="repo-access-details" v-if="isUpdate && detectedType === 'repo' && role === 'owner' && sourceUrl === 'https://github.com/' + _originalFullName">
|
||||
@@ -132,8 +128,9 @@
|
||||
<select class="form-control" id="branch" name="branch" v-field="{ state: viewState, set: value => { source.branch = value }, value: source?.branch, form: "anonymize", options: {} }">
|
||||
<option v-for="(b, index) in branches" v-text="b?.name" :value="(b?.name)"></option>
|
||||
</select>
|
||||
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-icon" type="button" title="Refresh" data-toggle="tooltip" data-placement="bottom" @click="getBranches(true)" aria-label="Refresh">
|
||||
<button class="btn btn-icon refresh-branches" type="button" title="Refresh branches" @click="getBranches(true)" aria-label="Refresh branches">
|
||||
<svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-refresh"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -155,17 +152,17 @@
|
||||
<div class="form-check" v-show="detectedType">
|
||||
<input class="form-check-input" type="checkbox" id="update" name="update" v-field="{ state: viewState, set: value => { options.update = value }, value: options?.update, form: "anonymize", options: {} }">
|
||||
<label class="form-check-label" for="update">Auto-update from GitHub</label>
|
||||
<small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
|
||||
<small class="form-text text-muted">Follows the latest version, at most once an hour.</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="paper-settings-section" v-show="detectedType">
|
||||
<div class="paper-section-eyebrow">Identity</div>
|
||||
<h2 class="paper-section-eyebrow">Anonymization</h2>
|
||||
|
||||
<div class="form-group" v-show="detectedType === 'repo'">
|
||||
<label class="paper-field-label" for="repoId">Anonymized repository ID</label>
|
||||
<input type="text" class="form-control" name="repoId" id="repoId" :disabled="isUpdate" v-field="{ state: viewState, set: value => { repoId = value }, value: repoId, form: "anonymize", options: { debounce: {default: 1000, blur: 0, click: 0}, updateOn: 'default blur click' } }" :class="{'is-invalid': anonymize?.repoId?.invalid}">
|
||||
<small class="form-text text-muted">Your share link will be <code>anonymous.4open.science/r/{{ repoId }}</code>.</small>
|
||||
<small class="form-text text-muted">anonymous.4open.science/r/{{ repoId }}</small>
|
||||
<div class="invalid-feedback" v-show="anonymize?.repoId?.errors?.format">ID can only contain letters and numbers.</div>
|
||||
<div class="invalid-feedback" v-show="anonymize?.repoId?.errors?.used">{{ repoId }} is already used.</div>
|
||||
</div>
|
||||
@@ -186,31 +183,20 @@
|
||||
<div class="invalid-feedback" v-show="anonymize?.gistId?.errors?.used">{{ gistId }} is already used.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="conference">Conference <span class="paper-optional">(optional)</span></label>
|
||||
<input class="form-control" id="conference" name="conference" v-field="{ state: viewState, set: value => { conference = value }, value: conference, form: "anonymize", options: { debounce: { default: 800, blur: 0 }, updateOn: 'default blur' } }" :class="{'is-invalid': anonymize?.conference?.invalid}">
|
||||
<small class="form-text text-muted" v-show="conference_data">
|
||||
<a target="_blank" :href="safeUrl((conference_data?.url))">{{ conference_data?.name }}</a> expires {{ fmt?.date(conference_data?.endDate) }}.
|
||||
</small>
|
||||
<div class="invalid-feedback" v-show="anonymize?.conference?.errors?.activated">The conference is not activated.</div>
|
||||
<small class="form-text text-muted" v-show="!conference_data">Link to a conference to apply its shared defaults.</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="paper-settings-section" v-show="detectedType">
|
||||
<div class="paper-section-eyebrow">Anonymization</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="terms">Terms to redact</label>
|
||||
<textarea class="form-control" id="terms" name="terms" rows="4" v-field="{ state: viewState, set: value => { terms = value }, value: terms, form: "anonymize", options: { debounce: 250 } }" :class="{'is-invalid': anonymize?.terms?.invalid}"></textarea>
|
||||
<small class="form-text text-muted">One term per line (regex allowed). Replaced by <code>{{ site_options?.ANONYMIZATION_MASK }}-[N]</code>, or use <code>term=>replacement</code> to pick your own (e.g. <code>Anonymous=>ABC</code>).</small>
|
||||
<textarea class="form-control" id="terms" name="terms" rows="3" v-field="{ state: viewState, set: value => { terms = value }, value: terms, form: "anonymize", options: { debounce: 250 } }" :class="{'is-invalid': anonymize?.terms?.invalid}"></textarea>
|
||||
<small class="form-text text-muted">One term per line.</small><details class="redaction-syntax"><summary>Regex & replacement syntax</summary> <small class="form-text text-muted">Regex is supported. Replaced by <code>{{ site_options?.ANONYMIZATION_MASK }}-[N]</code>, or use <code>term=>replacement</code> to pick your own (e.g. <code>Anonymous=>ABC</code>).</small></details>
|
||||
<div class="warning-feedback" v-show="termsRegexWarning">Regex characters detected. Escape them if unintentional.</div>
|
||||
<div class="invalid-feedback" v-show="anonymize?.terms?.errors?.format">Terms are in an invalid format.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="paper-settings-section" v-show="detectedType">
|
||||
<div class="paper-section-eyebrow">Display</div>
|
||||
<h2 class="paper-section-eyebrow">Display</h2>
|
||||
|
||||
<div v-show="detectedType === 'repo'">
|
||||
<div class="form-check">
|
||||
@@ -220,12 +206,12 @@
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="image" name="image" v-field="{ state: viewState, set: value => { options.image = value }, value: options?.image, form: "anonymize", options: {} }">
|
||||
<label class="form-check-label" for="image">Display images</label>
|
||||
<small class="form-text text-muted">Images are served unchanged and may contain names or affiliations.</small>
|
||||
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="pdf" name="pdf" v-field="{ state: viewState, set: value => { options.pdf = value }, value: options?.pdf, form: "anonymize", options: {} }">
|
||||
<label class="form-check-label" for="pdf">Display PDFs</label>
|
||||
<small class="form-text text-muted">PDF contents and metadata are served unchanged. <a href="/faq#formats">About supported formats</a></small>
|
||||
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="notebook" name="notebook" v-field="{ state: viewState, set: value => { options.notebook = value }, value: options?.notebook, form: "anonymize", options: {} }">
|
||||
@@ -243,6 +229,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="form-text text-muted" v-if="detectedType === 'repo' && (options.image || options.pdf)">Images and PDFs are served unchanged. Review their contents and metadata before sharing. <a href="/faq#formats">Supported formats</a></p>
|
||||
<div v-show="detectedType === 'gist'">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="title-gist" name="title-gist" v-field="{ state: viewState, set: value => { options.title = value }, value: options?.title, form: "anonymize", options: {} }">
|
||||
@@ -302,31 +289,64 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="paper-settings-section" v-show="detectedType">
|
||||
<div class="paper-section-eyebrow">Expiration</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="expiration">Strategy</label>
|
||||
<select class="form-control" id="expiration" name="expiration" v-field="{ state: viewState, set: value => { options.expirationMode = value }, value: options?.expirationMode, form: "anonymize", options: {} }">
|
||||
<option value="redirect">Redirect to GitHub when expired</option>
|
||||
<option value="remove" selected>Remove when expired</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="expirationDate">Expiration date</label>
|
||||
<section class="paper-settings-section hosting-settings" id="repository-hosting" v-show="detectedType" aria-labelledby="hosting-heading">
|
||||
<h2 class="paper-section-eyebrow" id="hosting-heading">Hosting & expiration</h2>
|
||||
<p class="hosting-usage" v-if="detectedType === 'repo' && billing?.enabled">
|
||||
<span v-if="isUpdate && role !== 'owner'">Uses the owner's hosting</span>
|
||||
<span v-else-if="hasAuthorHosting()">Your author subscription is active.</span>
|
||||
<span v-else-if="conference">Conference hosting is checked on submission.</span>
|
||||
<span v-else-if="hostingQuota?.repository">{{ hostingQuota.repository.used }} of {{ hostingQuota.repository.total }} active repositories · Free</span>
|
||||
</p>
|
||||
<fieldset class="expiration-choices">
|
||||
<legend class="sr-only">Expiration</legend>
|
||||
<label class="expiration-choice"><input type="radio" name="expiration-choice" :checked="options.expirationMode !== 'never'" @change="useDatedHosting()" :disabled="billingPending"><span>Expire on a date</span></label>
|
||||
<div class="dated-options" v-if="options.expirationMode !== 'never'">
|
||||
<div class="hosting-date" v-if="options?.expirationMode !== 'never'">
|
||||
<label class="paper-field-label" for="expirationDate">Expires</label>
|
||||
<input class="form-control" type="date" name="expirationDate" id="expirationDate" required v-field="{ state: viewState, set: value => { options.expirationDate = value }, value: options?.expirationDate, form: "anonymize", options: {} }" :class="{'is-invalid': anonymize?.expirationDate?.invalid}" :min="(minExpirationDate)" :max="(maxExpirationDate)">
|
||||
<div class="invalid-feedback" v-show="anonymize?.expirationDate?.errors?.min">Pick a date in the future.</div>
|
||||
<div class="invalid-feedback" v-show="anonymize?.expirationDate?.errors?.max">Pick a date on or before {{ fmt?.date(maxExpirationDate) }}.</div>
|
||||
<div class="invalid-feedback" v-show="anonymize?.expirationDate?.errors?.required || anonymize?.expirationDate?.errors?.date">Enter a valid expiration date.</div>
|
||||
</div>
|
||||
<div class="hosting-property"><label for="expiration">Then</label><select id="expiration" class="form-control" v-model="options.expirationMode"><option value="remove">Remove content</option><option value="redirect">Redirect to GitHub</option></select></div>
|
||||
</div>
|
||||
<label class="expiration-choice" v-if="detectedType === 'repo'"><input type="radio" name="expiration-choice" :checked="options.expirationMode === 'never'" @change="options.expirationMode = 'never'" :disabled="billingPending"><span>Never expire<small v-if="options.expirationMode !== 'never' || hasAuthorHosting()">{{ hasAuthorHosting() ? 'Included in your subscription' : 'Author subscription' }}</small></span></label>
|
||||
</fieldset>
|
||||
<p class="form-text text-muted" v-if="options.expirationMode === 'redirect'">Visitors will be redirected to the original GitHub source on this date, revealing its identity.</p>
|
||||
<div class="form-group conference-field">
|
||||
<label class="paper-field-label" for="conference">Conference code <span class="paper-optional">(optional)</span></label>
|
||||
<input class="form-control" id="conference" name="conference" v-field="{ state: viewState, set: value => { conference = value }, value: conference, form: "anonymize", options: { debounce: { default: 800, blur: 0 }, updateOn: 'default blur' } }" :class="{'is-invalid': anonymize?.conference?.invalid}">
|
||||
<small class="form-text text-muted" v-show="conference_data">
|
||||
<a target="_blank" :href="safeUrl((conference_data?.url))">{{ conference_data?.name }}</a> expires {{ fmt?.date(conference_data?.endDate) }}.
|
||||
</small>
|
||||
<div class="invalid-feedback" v-show="anonymize?.conference?.errors?.activated">The conference is not activated.</div>
|
||||
<small class="form-text text-muted" v-show="!conference_data">Link to a conference to apply its shared defaults.</small>
|
||||
</div>
|
||||
<div v-if="detectedType === 'repo' && billing?.enabled" class="inline-hosting">
|
||||
<p v-if="taskValidationError" role="alert" class="text-danger">{{ taskValidationError }}</p>
|
||||
<p v-if="billingError" role="alert" class="text-danger">{{ billingError }} <button type="button" class="task-text-button" @click="loadBilling()" :disabled="billingBusy">Reload billing</button></p>
|
||||
<div v-if="billingPending" role="status"><p><strong>Confirming your payment…</strong></p><p class="form-text text-muted">Your draft is saved. Please do not pay again.</p></div>
|
||||
<p v-else-if="billingNotice" role="status" class="form-text text-muted">{{ billingNotice }}</p>
|
||||
<template v-if="hostingNeedsPayment() && !billingPending">
|
||||
<p v-if="options.expirationMode !== 'never'" class="capacity-reason">{{ options.update && !hostingQuotaBlocked && hostingQuota?.repository?.used < hostingQuota?.repository?.total ? 'Automatic updates require Author hosting.' : 'Your free hosting allowance is full.' }}</p>
|
||||
<div class="subscription-choice" v-if="authorPlans().length && billing.checkoutEnabled !== false">
|
||||
<h3>Author subscription</h3>
|
||||
<fieldset class="billing-cycle"><legend class="sr-only">Billing frequency</legend><label class="cycle-option" v-for="plan in authorPlans()" :key="plan.id" :class="{'selected': selectedHostingPlan()?.id === plan.id}"><input type="radio" name="hosting-plan" :value="plan.id" :checked="selectedHostingPlan()?.id === plan.id" @change="hostingPlan = plan.id"><span>{{ billingPrice(plan) }}</span></label></fieldset>
|
||||
<p>{{ selectedHostingPlan()?.repositories }} repositories, {{ billingBytes(selectedHostingPlan()?.storage) }}. Coauthors included.</p>
|
||||
<p class="hosting-terms">Renews {{ selectedHostingPlan()?.interval === 'year' ? 'annually' : 'monthly' }} until cancelled. Tax shown at checkout.</p>
|
||||
<p class="hosting-terms">If paid access ends, never-expiring repositories expire after 90 days.</p>
|
||||
</div>
|
||||
<p v-else role="status">New purchases are temporarily unavailable. Your draft is kept.</p>
|
||||
<details class="capacity-options" v-if="options.expirationMode !== 'never'"><summary>Keep using free hosting</summary><p>Remove a repository you no longer need, or use conference hosting. Removing a repository affects its review link.</p><a href="/dashboard">Review my repositories</a><button type="button" class="task-text-button" @click="anonymizeRepo($event)" :disabled="sourceLoading || billingBusy">Recheck capacity and submit</button></details>
|
||||
</template>
|
||||
<p class="hosting-terms" v-else-if="options.expirationMode === 'never' && !billingPending">No scheduled expiration while subscribed. A 90-day grace period applies if paid access ends.</p>
|
||||
<details class="repository-pass" v-if="isUpdate && role === 'owner' && !hasAuthorHosting()"><summary>Hosting just this repository?</summary><div v-for="plan in (billing?.plans || []).filter(p => p.id === 'author_pass')" :key="plan.id"><p>{{ billingPrice(plan) }} · {{ billingBytes(plan.storage) }} for 180 days. No renewal. Does not include Never expire.</p><button type="button" class="btn btn-outline-ink" :disabled="billingBusy || hostingSaving || sourceLoading || billing.checkoutEnabled === false || billingPending" @click="checkoutRepository(true)">Review repository pass</button></div></details>
|
||||
</div>
|
||||
<small class="form-text text-muted" v-show="options?.expirationMode=='remove' && options?.expirationDate">After {{ fmt?.date(options?.expirationDate) }}, the content will be removed.</small>
|
||||
<small class="form-text text-muted" v-show="options?.expirationMode=='redirect' && options?.expirationDate">After {{ fmt?.date(options?.expirationDate) }}, visitors will be redirected to GitHub.</small>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="paper-settings-section" v-show="isUpdate && detectedType === 'repo'">
|
||||
<div class="paper-section-eyebrow">Co-authors</div>
|
||||
<h2 class="paper-section-eyebrow">Coauthors</h2>
|
||||
<p class="form-text text-muted" style="margin-bottom: 8px;">
|
||||
Co-authors can view and edit these settings. They cannot delete the anonymization or manage co-authors.
|
||||
</p>
|
||||
@@ -364,15 +384,14 @@
|
||||
|
||||
<div class="draft-status" role="status" v-if="draftRestored">Your unsaved draft was restored. <button type="button" class="draft-discard" @click="discardDraft()">Discard draft</button></div>
|
||||
<div class="draft-status" v-else-if="unsavedChanges">{{ draftSaved ? "Draft saved in this tab for 30 minutes." : "Unsaved changes. Keep this tab open until you save." }} <button type="button" class="draft-discard" @click="discardDraft()">{{ isUpdate ? "Restore saved settings" : "Discard changes" }}</button></div>
|
||||
<p class="anonymize-review-note" v-if="detectedType === 'repo' && (options.image || options.pdf)">Review enabled images and PDFs before sharing. Their contents are not anonymized.</p>
|
||||
<button type="button" class="btn btn-outline-ink mobile-preview-jump" @click="selectMobilePane('preview')">Review preview <span aria-hidden="true">→</span></button>
|
||||
|
||||
</div>
|
||||
<div class="anonymize-submit-bar" v-show="detectedType">
|
||||
<button type="submit" :disabled="sourceLoading" class="btn btn-ink" v-if="detectedType === 'repo' && !isUpdate" @click.prevent="submitForm($event, () => { anonymizeRepo($event) })">
|
||||
<svg class="ui-icon mr-1" aria-hidden="true"><use href="#ui-icon-shield"></use></svg> Anonymize Repository
|
||||
</button>
|
||||
<button type="submit" :disabled="sourceLoading" class="btn btn-ink" v-if="detectedType === 'repo' && isUpdate" @click.prevent="submitForm($event, () => { anonymizeRepo($event) })">
|
||||
<svg class="ui-icon mr-1" aria-hidden="true"><use href="#ui-icon-save"></use></svg> Update Repository
|
||||
</button>
|
||||
<template v-if="detectedType === 'repo'">
|
||||
<button type="button" class="btn btn-ink" v-if="billingPending" @click="refreshBilling()" :disabled="billingBusy">Check payment status</button>
|
||||
<button type="submit" class="btn btn-ink" v-else :disabled="sourceLoading || billingBusy || hostingSaving || billingLoading || (hostingNeedsPayment() && (!authorPlans().length || billing?.checkoutEnabled === false))" @click.prevent="submitForm($event, () => { submitRepository($event) })">{{ hostingSaving ? 'Saving draft…' : hostingNeedsPayment() ? 'Continue to checkout' : isUpdate ? 'Save changes' : 'Create anonymous link' }}</button>
|
||||
<p class="checkout-return-note" v-if="hostingNeedsPayment() && !billingPending">Review payment in Stripe, then return to finish.</p>
|
||||
</template>
|
||||
<button type="submit" :disabled="sourceLoading" class="btn btn-ink" v-if="detectedType === 'pr' && !isUpdate" @click.prevent="submitForm($event, () => { anonymizePullRequest($event) })">
|
||||
<svg class="ui-icon mr-1" aria-hidden="true"><use href="#ui-icon-shield"></use></svg> Anonymize Pull Request
|
||||
</button>
|
||||
@@ -392,8 +411,7 @@
|
||||
<!-- Preview column (right) -->
|
||||
<div id="workspace-preview" :role="mobileWorkspace ? 'tabpanel' : null" :aria-labelledby="mobileWorkspace ? 'workspace-preview-tab' : null" class="anonymize-preview-col" v-if="detectedType === 'repo'">
|
||||
<div class="anonymize-preview-head">
|
||||
<span class="paper-eyebrow">Live preview</span>
|
||||
<span class="anonymize-preview-sub">{{ previewStatus === 'ready' ? 'README with redactions applied' : 'README preview' }}</span>
|
||||
<strong>README.md</strong><button type="button" class="task-text-button preview-expand" @click="previewExpanded = !previewExpanded">{{ previewExpanded ? 'Restore split view' : 'Expand preview' }}</button>
|
||||
</div>
|
||||
<div class="anonymize-preview-status" role="status" v-if="sourceLoading && previewStatus === 'idle'">Loading source…</div>
|
||||
<div class="anonymize-preview-status" role="status" v-if="previewStatus === 'loading'">Updating the anonymized preview…</div>
|
||||
@@ -407,8 +425,7 @@
|
||||
|
||||
<div id="workspace-preview" :role="mobileWorkspace ? 'tabpanel' : null" :aria-labelledby="mobileWorkspace ? 'workspace-preview-tab' : null" class="anonymize-preview-col" v-if="detectedType === 'gist' && details">
|
||||
<div class="anonymize-preview-head">
|
||||
<span class="paper-eyebrow">Live preview</span>
|
||||
<span class="anonymize-preview-sub">{{ previewStatus === 'ready' ? 'Gist with redactions applied' : 'Gist preview' }}</span>
|
||||
<strong>Gist preview</strong><button type="button" class="task-text-button preview-expand" @click="previewExpanded = !previewExpanded">{{ previewExpanded ? 'Restore split view' : 'Expand preview' }}</button>
|
||||
</div>
|
||||
<div class="anonymize-preview-status" role="status" v-if="sourceLoading && previewStatus === 'idle'">Loading source…</div>
|
||||
<div class="anonymize-preview-status" role="status" v-if="previewStatus === 'loading'">Updating the anonymized preview…</div>
|
||||
@@ -460,8 +477,7 @@
|
||||
|
||||
<div id="workspace-preview" :role="mobileWorkspace ? 'tabpanel' : null" :aria-labelledby="mobileWorkspace ? 'workspace-preview-tab' : null" class="anonymize-preview-col" v-if="detectedType === 'pr' && details">
|
||||
<div class="anonymize-preview-head">
|
||||
<span class="paper-eyebrow">Live preview</span>
|
||||
<span class="anonymize-preview-sub">{{ previewStatus === 'ready' ? 'Pull request with redactions applied' : 'Pull request preview' }}</span>
|
||||
<strong>Pull request preview</strong><button type="button" class="task-text-button preview-expand" @click="previewExpanded = !previewExpanded">{{ previewExpanded ? 'Restore split view' : 'Expand preview' }}</button>
|
||||
</div>
|
||||
<div class="anonymize-preview-status" role="status" v-if="sourceLoading && previewStatus === 'idle'">Loading source…</div>
|
||||
<div class="anonymize-preview-status" role="status" v-if="previewStatus === 'loading'">Updating the anonymized preview…</div>
|
||||
|
||||
@@ -26,6 +26,22 @@
|
||||
<span v-if="conference?.price">Cost so far <b>{{ fmt?.number(conference?.price, 2) }} €</b></span>
|
||||
</div>
|
||||
|
||||
<div class="conference-payment-status" v-if="billingNotice" role="status">
|
||||
<p>{{ billingNotice }}</p>
|
||||
<button type="button" class="btn btn-outline-ink" @click="refreshBilling()" :disabled="billingBusy">Check payment status</button>
|
||||
</div>
|
||||
<p v-if="billingError && conference?.status !== 'pending_payment'" role="alert">{{ billingError }}</p>
|
||||
<section class="paper-settings-section" v-if="conference?.status === 'pending_payment'" aria-labelledby="conference-payment-heading">
|
||||
<h2 id="conference-payment-heading">Activate your review package</h2>
|
||||
<p>This conference is waiting for payment. Authors can join after payment is confirmed. Checkout returns to this conference. Authors within the package do not pay separately.</p>
|
||||
<p v-if="billingLoading" role="status">Loading payment options…</p>
|
||||
<p v-if="billingError" role="alert">{{ billingError }}</p>
|
||||
<div v-for="plan in (billing?.plans || []).filter(p => p.id === 'conference')" :key="plan.id">
|
||||
<p>{{ billingPrice(plan) }} for up to {{ plan.repositories }} repositories and {{ billingBytes(plan.storage) }} of source files during the review window. Taxes, if applicable, appear at checkout.</p>
|
||||
<button type="button" class="btn btn-primary" @click="checkout('conference', conference.conferenceID)" :disabled="billingBusy || billingPending || billing?.checkoutEnabled === false">{{ billingBusy ? 'Please wait…' : 'Pay for review package' }}</button>
|
||||
</div>
|
||||
<p><a href="/profile#settings-billing">Manage invoices</a></p>
|
||||
</section>
|
||||
<div class="paper-section-eyebrow">Repositories</div>
|
||||
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Filter repositories" accept-charset="UTF-8" v-form="viewState" @submit.prevent="submitForm($event, () => { $event.preventDefault() })">
|
||||
|
||||
+154
-30
@@ -12,6 +12,7 @@
|
||||
<div class="paper-faq-toc-head">Contents</div>
|
||||
<nav>
|
||||
<a href="#faq-general">General</a>
|
||||
<a href="#faq-plans">Plans and billing</a>
|
||||
<a href="#faq-features">Features</a>
|
||||
<a href="#faq-limitations">Limitations</a>
|
||||
<a href="#faq-privacy">Privacy & Security</a>
|
||||
@@ -37,7 +38,7 @@
|
||||
<div id="whatIs" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingWhatIs">
|
||||
<div class="panel-body p-3">
|
||||
<p>
|
||||
Anonymous GitHub is a free, open-source tool that anonymizes GitHub repositories and pull requests
|
||||
Anonymous GitHub is an open-source tool with free hosting and optional paid plans that anonymizes GitHub repositories and pull requests
|
||||
for double-anonymous (double-blind) peer review. It replaces identifying information — such as
|
||||
the repository owner, organization name, and custom terms — so that reviewers cannot determine
|
||||
the identity of the authors through the code repository.
|
||||
@@ -99,13 +100,148 @@
|
||||
<div id="cost" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingCost">
|
||||
<div class="panel-body p-3">
|
||||
<p>
|
||||
Anonymous GitHub is completely free to use. However, the server costs hundreds of dollars per year
|
||||
to maintain. If you find the service useful, a small donation would be greatly appreciated. You can
|
||||
support the project by
|
||||
<a href="https://github.com/sponsors/tdurieux/" target="_blank">sponsoring on GitHub</a>,
|
||||
donating through <a href="https://ko-fi.com/tdurieux" target="_blank">Ko-fi</a>, or by clicking
|
||||
the "Support me" button on the site.
|
||||
A free account includes 5 owned repositories and 2 GiB of source files. Optional paid plans
|
||||
offer more capacity, automatic refresh, and, with an author subscription, never-expiring repositories.
|
||||
Reviewers can read shared repositories without an account or payment.
|
||||
</p>
|
||||
<p>
|
||||
See <a href="/profile#settings-billing">Plans and billing</a> for available plans and current prices.
|
||||
Offers depend on whether payments are enabled on this instance; the checkout shows the amount before
|
||||
you pay. Subscriptions help fund hosting, storage, backups, and continued maintenance.
|
||||
</p>
|
||||
<p>
|
||||
You can also support development through
|
||||
<a href="https://github.com/sponsors/tdurieux/" target="_blank" rel="noopener">GitHub Sponsors</a>
|
||||
or <a href="https://ko-fi.com/tdurieux" target="_blank" rel="noopener">Ko-fi</a>.
|
||||
Donations are separate from subscriptions and do not automatically unlock paid features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plans and billing -->
|
||||
<h4 class="faq-cat" id="faq-plans-title">Plans and billing</h4>
|
||||
<div class="panel-group" id="faq-plans" role="tablist" aria-label="Plans and billing" aria-multiselectable="true">
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingFreePlan">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#freePlan" aria-expanded="false" aria-controls="freePlan">What is included in a free account?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="freePlan" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingFreePlan">
|
||||
<div class="panel-body p-3">
|
||||
<p>The default free allowance is 5 repositories you own and 2 GiB of source files in total. Core anonymization and reviewer access remain free. You can manually refresh a repository within your allowance and choose a dated expiration up to one year ahead.</p>
|
||||
<p>Repositories shared with you as a coauthor do not use your own slots. Gists and pull requests are not included in these repository quotas. The usage shown in your account is the allowance available on this instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingAuthorPlans">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#authorPlans" aria-expanded="false" aria-controls="authorPlans">What do monthly and annual author subscriptions include?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="authorPlans" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingAuthorPlans">
|
||||
<div class="panel-body p-3">
|
||||
<p>Both include 20 owned repositories and 10 GiB of source files in total. They also allow automatic refresh when enabled in repository settings and let the owner choose <strong>Never expire</strong>.</p>
|
||||
<p>Monthly and annual plans offer the same features with different billing periods. They renew automatically until you cancel renewal. Current prices are shown in <a href="/profile#settings-billing">Plans and billing</a>.</p>
|
||||
<p>Paid plans do not change how anonymization works. File-size limits and other service safeguards still apply. Review your anonymized content before sharing it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingReviewPass">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#reviewPass" aria-expanded="false" aria-controls="reviewPass">Can I pay for one repository without subscribing?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="reviewPass" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingReviewPass">
|
||||
<div class="panel-body p-3">
|
||||
<p>The author review pass covers one selected repository for 180 days, with a separate 2 GiB allowance and automatic refresh when enabled. It is a one-time purchase and does not renew automatically.</p>
|
||||
<p>Enter the anonymized ID of an existing repository you own in <a href="/profile#settings-billing">Plans and billing</a>. A pass does not unlock Never expire and does not override the repository's chosen expiration date.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingConferencePlans">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#conferencePlans" aria-expanded="false" aria-controls="conferencePlans">Can a conference or lab pay for a group?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="conferencePlans" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingConferencePlans">
|
||||
<div class="panel-body p-3">
|
||||
<p>A conference review package provides a shared allowance of 100 repositories and 50 GiB for a review window of up to 180 days. The organizer pays once; the package does not renew automatically. A lab organizing a review can use this shared package too.</p>
|
||||
<p>The paid conference must be activated by payment before authors can submit under its Conference ID. Eligible repositories use the conference allowance before an author's personal allowance, so authors do not each need a subscription for that capacity.</p>
|
||||
<p>The package has a fixed review window. Never expire still requires the repository owner's monthly or annual author subscription. See the available offer when creating a conference.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingPaidCoauthors">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#paidCoauthors" aria-expanded="false" aria-controls="paidCoauthors">Does every coauthor need to pay?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="paidCoauthors" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingPaidCoauthors">
|
||||
<div class="panel-body p-3">
|
||||
<p>No. Coauthors can collaborate without buying a subscription. A shared repository uses its owner's allowance, unless a repository pass or paid conference covers it. It does not consume each coauthor's personal allowance.</p>
|
||||
<p>A coauthor's subscription does not increase the owner's allowance. If the owner's available capacity is exhausted, a coauthor's refresh or edit may also be blocked. Reviewers never need a paid account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingCapacity">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#capacity" aria-expanded="false" aria-controls="capacity">What counts toward my allowance, and what happens at the limit?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="capacity" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingCapacity">
|
||||
<div class="panel-body p-3">
|
||||
<p>The repository limit counts work you own. Storage counts the source-file sizes reserved for those repositories, not just files a reviewer opens. Archived or failed repositories can still reserve capacity, and a refresh does not automatically reduce a previous storage reservation.</p>
|
||||
<p>At the repository limit you cannot add another repository to that allowance. An edit or refresh must also fit the available storage. Remove work you no longer need, choose a paid plan, or use an eligible conference allowance.</p>
|
||||
<p>Removing a repository releases its reservation when capacity is next checked. Expired repositories may continue to reserve capacity until cleanup. Reaching a limit does not itself delete existing review links.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingNeverExpire">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#neverExpire" aria-expanded="false" aria-controls="neverExpire">What happens to Never expire repositories if I stop subscribing?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="neverExpire" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingNeverExpire">
|
||||
<div class="panel-body p-3">
|
||||
<p>Never expire is available to monthly and annual author subscribers. If paid access ends, these repositories remain available for a <strong>90-day grace period</strong>, then expire and their hosted content is removed.</p>
|
||||
<p>Renewing during the grace period keeps them active. After expiration, you must renew and explicitly reactivate the repository; restoring content also requires access to its GitHub source. A repository does not become public on GitHub when it expires.</p>
|
||||
<p>Existing never-expiring repositories created before this billing policy retain their settings. Never expire is not a guarantee of perpetual service availability.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingCancelPlan">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#cancelPlan" aria-expanded="false" aria-controls="cancelPlan">How do I cancel, and what happens after paid access ends?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="cancelPlan" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingCancelPlan">
|
||||
<div class="panel-body p-3">
|
||||
<p>Open <a href="/profile#settings-billing">Plans and billing</a> and choose <strong>Manage billing and invoices</strong> to manage renewal. Cancelling renewal keeps subscription benefits until the end of the paid period.</p>
|
||||
<p>After paid access ends, repositories fall back to another eligible allowance or the free allowance. Automatic refresh pauses on the free allowance; manual refresh remains available within its limits. Existing review links keep their configured expiration dates, except paid Never expire repositories, which follow the 90-day grace period described above.</p>
|
||||
<p>A repository pass or conference package ends after its paid window without automatic renewal.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default mb-3">
|
||||
<div class="panel-heading p-3" role="tab" id="headingPaymentHandling">
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#faq-plans" href="#paymentHandling" aria-expanded="false" aria-controls="paymentHandling">How are payments handled?</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="paymentHandling" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingPaymentHandling">
|
||||
<div class="panel-body p-3">
|
||||
<p>Stripe processes checkout and billing management. Anonymous GitHub does not store your card details. You can access billing management and invoices from <a href="/profile#settings-billing">Plans and billing</a>.</p>
|
||||
<p>Paid access is activated after payment is confirmed. If checkout succeeds but your account has not updated, choose <strong>Refresh payment status</strong> before trying another purchase. If paid plans are unavailable on this instance, no checkout is offered.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +333,7 @@
|
||||
You can configure one of three expiration strategies when creating your anonymized repository:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Never expire</strong> — the anonymized repository remains accessible indefinitely.</li>
|
||||
<li><strong>Never expire</strong>: with a monthly or annual author subscription, no scheduled expiration while subscribed. A 90-day grace period applies after paid access ends. See <a href="#neverExpire">the subscription expiration policy</a>.</li>
|
||||
<li><strong>Redirect to GitHub</strong> — after expiration, visitors are redirected to the original GitHub repository.</li>
|
||||
<li><strong>Remove content</strong> — after expiration, the anonymized content is deleted from the server.</li>
|
||||
</ul>
|
||||
@@ -235,29 +371,15 @@
|
||||
<div id="updates" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingUpdates">
|
||||
<div class="panel-body p-3">
|
||||
<p>
|
||||
Yes. The anonymized repository tracks the source on GitHub: when you push new commits to the
|
||||
original repository, the anonymized view picks up those changes (cached files are refreshed
|
||||
against GitHub). This means you can keep iterating on the code while reviewers have the link,
|
||||
and you do not need to recreate the anonymized repository every time you update the source.
|
||||
You can refresh the anonymized repository from GitHub without creating a new share link.
|
||||
Free accounts can request a manual refresh within their allowance. Automatic refresh requires an
|
||||
eligible paid allowance and must be enabled in repository settings. It pauses when the repository
|
||||
falls back to the free allowance.
|
||||
</p>
|
||||
<p>
|
||||
A few practical implications worth keeping in mind:
|
||||
After changing files or contributor names, review your anonymization terms before sharing the
|
||||
updated content. If GitHub access is revoked or the source is deleted, the service cannot fetch updates.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
You can safely create the anonymized repository early in the writing process — later
|
||||
commits will be visible to reviewers without any additional action.
|
||||
</li>
|
||||
<li>
|
||||
If you rename files, add new identifiers, or introduce new contributor names after creation,
|
||||
revisit the anonymization options (custom terms, file filters) to make sure the new content
|
||||
is still properly anonymized.
|
||||
</li>
|
||||
<li>
|
||||
If the original repository is made private or deleted, the anonymized repository will no
|
||||
longer be able to fetch updated content.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,8 +458,8 @@ anonymous_github</code></pre>
|
||||
<div id="data" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingData">
|
||||
<div class="panel-body p-3">
|
||||
<p>
|
||||
Data stored on Anonymous GitHub is never shared or used for any purpose beyond providing the
|
||||
anonymization service. When a repository is removed or expires, only its configuration is
|
||||
Repository data is used to provide the anonymization service. If you purchase a plan, Stripe
|
||||
processes payment and billing information. When a repository is removed or expires, only its configuration is
|
||||
retained — this makes it easy to restore the repository if needed and ensures that no
|
||||
future repository reuses the same ID.
|
||||
</p>
|
||||
@@ -399,6 +521,8 @@ anonymous_github</code></pre>
|
||||
<div class="panel-body p-3">
|
||||
<p>
|
||||
Yes. Anonymous GitHub is fully open source (GPL-3.0) and supports Docker-based deployment.
|
||||
Billing is optional and disabled by default for self-hosted instances; paid hosted plans are not
|
||||
required to run your own server.
|
||||
You will need to configure a GitHub OAuth app and provide a GitHub token. Detailed setup
|
||||
instructions are available on the
|
||||
<a href="https://github.com/tdurieux/anonymous_github/" target="_blank">GitHub repository</a>.
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
Sign in
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" v-if="user?.username && headerCanUpgrade && !headerBillingPending">
|
||||
<a class="nav-link nav-upgrade" href="/profile?upgrade=1" @click.prevent="openHeaderUpgrade()">Upgrade</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown user-chip-wrap" v-if="user">
|
||||
<a class="nav-link user-chip dropdown-toggle" href="#" id="navbarDropdownMenuLink" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<img width="22" height="22" class="rounded-circle" v-if="user?.photo" :src="safeUrl((user?.photo))">
|
||||
|
||||
@@ -129,15 +129,15 @@
|
||||
|
||||
<section id="conf-plan" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Plan</div>
|
||||
<p class="paper-section-copy">Billed per repository per month, prorated by the day for as long as a repository stays in the conference.</p>
|
||||
<p class="paper-section-copy">The review package is prepaid and covers up to 180 days. Pay from the conference page after creating it. Plan and review dates cannot change after creation.</p>
|
||||
|
||||
<div class="paper-plan-grid">
|
||||
<label class="paper-plan-card" v-for="(plan, index) in plans" :class="{'selected': options?.plan?.planID == plan?.id}">
|
||||
<input type="radio" name="planPicker" v-field="{ state: viewState, set: value => { options.plan.planID = value }, value: options?.plan?.planID, form: "conference", options: {} }" :value="plan?.id">
|
||||
<input type="radio" :disabled="editionMode" name="planPicker" v-field="{ state: viewState, set: value => { options.plan.planID = value }, value: options?.plan?.planID, form: "conference", options: {} }" :value="plan?.id">
|
||||
<div class="paper-plan-head" v-text="plan?.name"></div>
|
||||
<div class="paper-plan-price">
|
||||
{{ fmt?.number(plan?.pricePerRepo) }}€
|
||||
<span class="paper-plan-per">/ repo / month</span>
|
||||
{{ plan.id === "free_conference" ? "Free" : "Prepaid package" }}
|
||||
<span class="paper-plan-per">{{ plan.id === "free_conference" ? "" : "Price on the conference page" }}</span>
|
||||
</div>
|
||||
<div class="paper-plan-desc" v-html="sanitize(plan?.description)"></div>
|
||||
</label>
|
||||
|
||||
+72
-138
@@ -1,142 +1,72 @@
|
||||
<div class="container paper-page paper-settings">
|
||||
<div class="paper-crumbs"><a href="/dashboard">My work</a> / <span class="here">Settings</span></div>
|
||||
<h1 class="paper-page-title">Your <em>settings</em></h1>
|
||||
<p class="paper-page-lede">Defaults applied to every new anonymization, your quotas, and your account.</p>
|
||||
|
||||
<div class="paper-settings-body">
|
||||
<aside class="paper-settings-toc" v-paper-scrollspy>
|
||||
<div class="paper-settings-toc-head">On this page</div>
|
||||
<nav>
|
||||
<a href="#settings-quota">Quota</a>
|
||||
<a href="#settings-terms">Terms to redact</a>
|
||||
<a href="#settings-display">Display</a>
|
||||
<a href="#settings-features">Features</a>
|
||||
<a href="#settings-expiration">Expiration</a>
|
||||
<a href="#settings-account">Account</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="paper-settings-main">
|
||||
<!-- Account usage shares the dashboard component. -->
|
||||
<section id="settings-quota" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Quota</div>
|
||||
<p class="paper-section-copy">What you are using across all your anonymizations. Conferences can lift these limits during a review window.</p>
|
||||
<quota-usage :quota="quota" :fmt="fmt" v-if="quota"></quota-usage>
|
||||
<div class="quota-row" aria-hidden="true" v-if="!quota">
|
||||
<div class="quota-item" v-for="(i, index) in [1, 2, 3]">
|
||||
<div class="quota-header"><span class="skeleton skeleton-line" style="width: 40%"></span><span class="skeleton skeleton-line" style="width: 25%"></span></div>
|
||||
<div class="quota-track"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="form needs-validation" name="defaultsForm" novalidate v-form="viewState" @submit.prevent="submitForm($event, () => { saveDefault($event) })">
|
||||
<section id="settings-terms" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Terms to redact</div>
|
||||
<p class="paper-section-copy">Pre-filled in every new anonymization. You can still edit the list per anonymization.</p>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="terms">Default terms</label>
|
||||
<textarea class="form-control" id="terms" name="terms" rows="4" placeholder="Jane Smith
|
||||
<div class="container paper-page user-settings">
|
||||
<header class="settings-title"><h1>Settings</h1><div class="settings-header-actions"><span>@{{ user?.username }}</span></div></header>
|
||||
<nav class="settings-nav" aria-label="Settings">
|
||||
<button type="button" v-for="tab in [{id:'defaults',label:'Anonymization defaults'},{id:'billing',label:'Hosting & billing'},{id:'account',label:'Account'}]" :aria-current="settingsTab === tab.id ? 'page' : null" @click="settingsTab = tab.id">{{ tab.label }}</button>
|
||||
</nav>
|
||||
<div v-show="settingsTab === 'defaults'">
|
||||
<p class="settings-scope">For new anonymizations only. Existing repositories keep their settings.</p>
|
||||
<form class="form needs-validation" name="defaultsForm" novalidate v-form="viewState" @submit.prevent="submitForm($event, () => { saveDefault($event) })">
|
||||
<section class="settings-row"><div><label class="settings-label" for="terms">Terms to redact</label><p class="settings-help">One term per line.</p></div><div><textarea class="form-control" id="terms" name="terms" rows="3" placeholder="Jane Smith
|
||||
MIT CSAIL
|
||||
jane.smith@example.org" v-field="{ state: viewState, set: value => { terms = value }, value: terms, form: "defaultsForm", options: { debounce: 250 } }"></textarea>
|
||||
<small id="termsHelp" class="form-text text-muted">
|
||||
One term per line (regex allowed). Each match is replaced by
|
||||
<code>{{ site_options?.ANONYMIZATION_MASK || 'XXX' }}-[N]</code>, or use <code>term=>replacement</code> to pick your own.
|
||||
</small>
|
||||
<div class="invalid-feedback" v-show="defaultsForm?.terms?.errors?.format">
|
||||
Terms are in an invalid format
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings-display" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Display</div>
|
||||
<p class="paper-section-copy">What reviewers can see inside an anonymized repository.</p>
|
||||
<div class="form-group paper-check-list">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="link" name="link" v-field="{ state: viewState, set: value => { options.link = value }, value: options?.link, form: "defaultsForm", options: {} }">
|
||||
<label class="form-check-label" for="link">Keep links</label>
|
||||
<small class="form-text text-muted">Unchecked, every link in text files is removed.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="image" name="image" v-field="{ state: viewState, set: value => { options.image = value }, value: options?.image, form: "defaultsForm", options: {} }">
|
||||
<label class="form-check-label" for="image">Display images</label>
|
||||
<small class="form-text text-muted">Images are shown as is. They are not anonymized.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="pdf" name="pdf" v-field="{ state: viewState, set: value => { options.pdf = value }, value: options?.pdf, form: "defaultsForm", options: {} }">
|
||||
<label class="form-check-label" for="pdf">Display PDFs</label>
|
||||
<small class="form-text text-muted">PDFs are shown as is. They are not anonymized.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="notebook" name="notebook" v-field="{ state: viewState, set: value => { options.notebook = value }, value: options?.notebook, form: "defaultsForm", options: {} }">
|
||||
<label class="form-check-label" for="notebook">Display notebooks</label>
|
||||
<small class="form-text text-muted">Render Jupyter notebooks instead of raw JSON.</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings-features" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Features</div>
|
||||
<div class="form-group paper-check-list">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="page" name="page" v-field="{ state: viewState, set: value => { options.page = value }, value: options?.page, form: "defaultsForm", options: {} }">
|
||||
<label class="form-check-label" for="page">GitHub Pages</label>
|
||||
<small class="form-text text-muted">Serve an anonymized copy of the repository's GitHub Pages site. Only pages built from the anonymized branch are supported.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="loc" name="loc" v-field="{ state: viewState, set: value => { options.loc = value }, value: options?.loc, form: "defaultsForm", options: {} }">
|
||||
<label class="form-check-label" for="loc">Lines of code</label>
|
||||
<small class="form-text text-muted">Show the line count of the repository in the explorer.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="update" name="update" v-field="{ state: viewState, set: value => { options.update = value }, value: options?.update, form: "defaultsForm", options: {} }">
|
||||
<label class="form-check-label" for="update">Auto-update from GitHub</label>
|
||||
<small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="mode">Fetch mode</label>
|
||||
<select class="form-control" id="mode" name="mode" v-field="{ state: viewState, set: value => { options.mode = value }, value: options?.mode, form: "defaultsForm", options: {} }">
|
||||
<option value="GitHubStream" selected>Stream from GitHub on demand</option>
|
||||
<option value="GitHubDownload">Download to Anonymous GitHub</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Download is faster for reviewers and enables every feature. Streaming is the only option above
|
||||
{{ fmt?.humanFileSize(site_options?.MAX_REPO_SIZE * 1024) }}.
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings-expiration" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Expiration</div>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="expiration">When an anonymization expires</label>
|
||||
<select class="form-control" id="expiration" name="expiration" v-field="{ state: viewState, set: value => { options.expirationMode = value }, value: options?.expirationMode, form: "defaultsForm", options: {} }">
|
||||
<details class="settings-syntax"><summary>Regex & replacement syntax</summary><p>Matches become <code>{{ site_options?.ANONYMIZATION_MASK || 'XXX' }}-[N]</code>. Use <code>term=>replacement</code> for a custom replacement. Regex is supported.</p></details>
|
||||
<div class="invalid-feedback" v-show="defaultsForm?.terms?.errors?.format">Terms are in an invalid format.</div>
|
||||
</div></section>
|
||||
<section class="settings-row"><h2>Reviewer display</h2><div><div class="settings-checks"><label class="settings-check"><input class="form-check-input" type="checkbox" id="link" name="link" v-field="{ state: viewState, set: value => { options.link = value }, value: options?.link, form: "defaultsForm", options: {} }">Keep links</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="notebook" name="notebook" v-field="{ state: viewState, set: value => { options.notebook = value }, value: options?.notebook, form: "defaultsForm", options: {} }">Render notebooks</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="image" name="image" v-field="{ state: viewState, set: value => { options.image = value }, value: options?.image, form: "defaultsForm", options: {} }">Show images</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="pdf" name="pdf" v-field="{ state: viewState, set: value => { options.pdf = value }, value: options?.pdf, form: "defaultsForm", options: {} }">Show PDFs</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="loc" name="loc" v-field="{ state: viewState, set: value => { options.loc = value }, value: options?.loc, form: "defaultsForm", options: {} }">Show line counts</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="page" name="page" v-field="{ state: viewState, set: value => { options.page = value }, value: options?.page, form: "defaultsForm", options: {} }">GitHub Pages</label></div><p class="settings-help">Images and PDFs are not anonymized.</p><p class="settings-help">GitHub Pages must use the anonymized branch.</p></div></section>
|
||||
<section class="settings-row"><h2>Source updates</h2><div><label class="settings-check"><input class="form-check-input" type="checkbox" id="update" name="update" v-field="{ state: viewState, set: value => { options.update = value }, value: options?.update, form: "defaultsForm", options: {} }">Auto-update from GitHub</label><p class="settings-help">Latest commit, at most once an hour.</p></div></section>
|
||||
<section class="settings-row"><label class="settings-label" for="expiration">On expiration</label><div><select class="form-control" id="expiration" name="expiration" v-field="{ state: viewState, set: value => { options.expirationMode = value }, value: options?.expirationMode, form: "defaultsForm", options: {} }">
|
||||
<option value="redirect">Redirect visitors to the GitHub repository</option>
|
||||
<option value="remove" selected>Remove the anonymized repository</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
The expiration date itself is chosen per anonymization. Redirecting reveals the source repository, so pick it only once the review is over.
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="draft-status" role="status" v-if="draftRestored">Your unsaved draft was restored. <button type="button" class="draft-discard" @click="discardDraft()">Discard draft</button></div>
|
||||
<div class="draft-status" v-else-if="unsavedChanges">{{ draftSaved ? "Draft saved in this tab for 30 minutes." : "Unsaved changes. Keep this tab open until you save." }} <button type="button" class="draft-discard" @click="discardDraft()">{{ isUpdate ? "Restore saved settings" : "Discard changes" }}</button></div>
|
||||
<div class="paper-settings-footer">
|
||||
<div class="paper-inline-alert paper-inline-alert-error" role="alert" v-if="error"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-info"></use></svg> <span v-text="error"></span></div>
|
||||
<div class="paper-settings-actions">
|
||||
<button id="save" type="submit" class="btn btn-ink" :disabled="saving">
|
||||
<svg class="ui-icon mr-1" aria-hidden="true" v-if="message"><use href="#ui-icon-check"></use></svg>{{ saving ? 'Saving…' : (message ? 'Saved' : 'Save defaults') }}
|
||||
</button>
|
||||
<span class="paper-settings-hint">Applies to new anonymizations only. Existing ones keep their settings.</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="settings-account" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Account</div>
|
||||
</select><p class="settings-help">{{ options.expirationMode === 'redirect' ? 'Redirecting reveals the original repository. Choose the date per anonymization.' : 'Set the expiration date for each repository.' }}</p></div></section>
|
||||
<footer class="settings-save">
|
||||
<div class="settings-save-status draft-status" role="status"><span v-if="draftRestored">Your unsaved draft was restored.</span><span v-else-if="unsavedChanges">Unsaved changes{{ draftSaved ? ' · Draft saved in this tab' : '' }}</span><span v-else>{{ message ? 'Defaults saved' : 'All changes saved' }}</span></div>
|
||||
<button type="button" class="draft-discard" v-if="draftRestored || unsavedChanges" @click="discardDraft()">Discard changes</button>
|
||||
<button id="save" type="submit" class="btn btn-ink" :disabled="saving">{{ saving ? 'Saving…' : 'Save defaults' }}</button>
|
||||
<div class="settings-save-error" role="alert" v-if="error">{{ error }}</div>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
<section id="settings-billing" v-show="settingsTab === 'billing'" :aria-busy="billingLoading || billingBusy">
|
||||
<p class="settings-scope">Your plan and usage. Reviewer access is always free.</p>
|
||||
<p v-if="billingLoading" role="status">Loading billing…</p>
|
||||
<div v-if="billingError" class="settings-notice" role="alert">{{ billingError }} <button type="button" class="btn btn-outline-ink" @click="loadBilling()" :disabled="billingBusy">Try again</button></div>
|
||||
<p v-if="billingNotice" class="settings-notice" role="status">{{ billingNotice }}</p>
|
||||
<div v-if="billingPending" class="settings-notice" role="status"><strong>Confirming your payment</strong><p>Your allowance will update after confirmation. Please do not pay again.</p><button type="button" class="btn btn-outline-ink" @click="refreshBilling()" :disabled="billingBusy">Check payment status</button></div>
|
||||
<section class="settings-row"><h2>Current plan</h2><div>
|
||||
<div class="settings-inline"><div><strong>{{ profileAuthorGrant() ? 'Author subscription' : (billing?.enabled ? 'Free' : 'Account allowance') }}</strong><p class="settings-help" v-if="profileAuthorGrant()">{{ profileAuthorGrant().cancelAtPeriodEnd ? 'Renewal cancelled · Access until ' : 'Paid access through ' }}{{ billingDate(profileAuthorGrant().until) }}</p></div>
|
||||
<button type="button" class="btn btn-outline-ink" v-if="billing?.enabled && !profileAuthorGrant() && !billingPending && !showAuthorPlans && profileAuthorPlans().length" @click="openProfileUpgrade()">View Author plan</button>
|
||||
</div>
|
||||
<p class="settings-help" v-if="billing?.allowance">{{ billing.allowance.repositories }} repositories · {{ billingBytes(billing.allowance.storage) }}</p>
|
||||
<div id="settings-quota"><quota-usage :quota="quota" :fmt="fmt" v-if="quota"></quota-usage><p v-else role="status">Loading usage…</p></div>
|
||||
<p class="settings-help">Coauthored repositories use their owner's allowance. Conference hosting is separate.</p>
|
||||
<p class="settings-notice" v-if="quota?.repository && !quota.repository.unlimited && quota.repository.used >= quota.repository.total">Your repository allowance is full. <a href="/dashboard">Review your repositories</a> to remove work you no longer need.</p>
|
||||
<p class="settings-notice" v-if="profilePastSubscription()">Your previous subscription is no longer active. Never-expiring repositories receive a 90-day grace period after paid access ends. <a href="/dashboard">Review repository expiration dates</a>.</p>
|
||||
<p class="settings-notice" v-if="profileAuthorGrant()?.status === 'past_due'">A subscription payment is overdue. Use Manage billing to update your payment details.</p>
|
||||
<p class="settings-help" v-if="profileAuthorGrant()?.cancelAtPeriodEnd">When paid access ends, never-expiring repositories enter a 90-day grace period. Dated repositories keep their expiration dates.</p>
|
||||
<div class="settings-plan billing-plan" v-if="showAuthorPlans && !profileAuthorGrant() && !billingPending && profileAuthorPlans().length">
|
||||
<h3 id="settings-plan-comparison" tabindex="-1">Compare plans</h3>
|
||||
<table class="settings-comparison"><caption class="sr-only">Free and Author hosting</caption><thead><tr><th scope="col">Included</th><th scope="col">Free</th><th scope="col">Author</th></tr></thead><tbody>
|
||||
<tr><th scope="row">Active repositories</th><td>{{ billing.allowance?.repositories }}</td><td>{{ profileSelectedPlan()?.repositories }}</td></tr>
|
||||
<tr><th scope="row">Source storage</th><td>{{ billingBytes(billing.allowance?.storage) }}</td><td>{{ billingBytes(profileSelectedPlan()?.storage) }}</td></tr>
|
||||
<tr><th scope="row">Expiration</th><td>Scheduled</td><td>Scheduled or never</td></tr>
|
||||
<tr><th scope="row">Coauthors</th><td>Included</td><td>Included</td></tr>
|
||||
<tr><th scope="row">Reviewer access</th><td>Free</td><td>Free</td></tr>
|
||||
</tbody></table>
|
||||
<fieldset class="settings-cycles"><legend class="sr-only">Billing frequency</legend><label v-for="plan in profileAuthorPlans()" :class="{selected: profileSelectedPlan()?.id === plan.id}"><input type="radio" name="profile-plan" :checked="profileSelectedPlan()?.id === plan.id" @change="profilePlan = plan.id">{{ billingPrice(plan) }}</label></fieldset>
|
||||
<p class="settings-help">Renews {{ profileSelectedPlan()?.interval === 'year' ? 'annually' : 'monthly' }} until cancelled. Tax shown at checkout.</p><p class="settings-help">If paid access ends, never-expiring repositories expire after a 90-day grace period.</p>
|
||||
<div class="settings-actions"><button type="button" class="btn btn-ink" :disabled="billingBusy || billing.checkoutEnabled === false" @click="checkout(profileSelectedPlan().id)">{{ billingBusy ? 'Please wait…' : 'Continue to checkout' }}</button><button type="button" class="btn btn-outline-ink" @click="showAuthorPlans = false">Keep current plan</button></div>
|
||||
</div>
|
||||
<p class="settings-help" v-if="billing && (!billing.enabled || billing.checkoutEnabled === false)">New purchases are not available. Existing billing can still be managed below.</p>
|
||||
</div></section>
|
||||
<section class="settings-row"><h2>Billing</h2><div><div class="settings-inline" v-if="billing?.portal"><p class="settings-help">Invoices, payment details, and renewal.</p><button type="button" class="btn btn-outline-ink" @click="billingPortal()" :disabled="billingBusy">Manage billing</button></div><p class="settings-help" v-else>No billing account available.</p><button type="button" class="settings-text-button" v-if="billing?.enabled && !billingPending" @click="refreshBilling()" :disabled="billingBusy">Refresh payment status</button></div></section>
|
||||
<section class="settings-row" v-if="billing?.enabled"><h2>Repository passes</h2><div><div class="settings-inline"><p class="settings-help">One repository for 180 days.<br>No subscription or automatic renewal.</p><button type="button" class="btn btn-outline-ink" v-if="profilePassPlan()" @click="showRepositoryPass = !showRepositoryPass" :disabled="billingPending">Review a pass</button></div>
|
||||
<div class="settings-pass" v-if="showRepositoryPass && profilePassPlan() && !billingPending"><label for="billing-repository">Repository ID you own</label><input id="billing-repository" class="form-control" v-model="billingRepository" placeholder="Existing anonymized repository ID" autocomplete="off"><p class="settings-help">{{ billingPrice(profilePassPlan()) }} · {{ billingBytes(profilePassPlan().storage) }} · Never expire is not included.</p><button type="button" class="btn btn-ink" :disabled="billingBusy || !billingRepository.trim() || billing.checkoutEnabled === false" @click="checkout('author_pass', billingRepository.trim())">Continue to checkout</button></div>
|
||||
</div></section>
|
||||
<section class="settings-row" v-if="billing?.grants?.length"><h2>Your purchases</h2><div><div class="settings-purchase" v-for="grant in billing.grants" :key="grant.scope + grant.planId + grant.until"><strong>{{ profileGrantName(grant) }}</strong><span>{{ grant.scope }}</span><p class="settings-help">{{ grant.status }}<template v-if="grant.status !== 'revoked'"> · Paid access through {{ billingDate(grant.until) }}{{ grant.cancelAtPeriodEnd ? ' · Renewal cancelled' : '' }}</template></p></div></div></section>
|
||||
<section class="settings-row"><h2>Conference hosting</h2><div class="settings-inline"><p class="settings-help">Shared hosting is managed per conference.</p><a href="/conferences" class="btn btn-outline-ink">My conferences</a></div></section>
|
||||
</section>
|
||||
<section id="settings-account" class="settings-account-panel" v-show="settingsTab === 'account'">
|
||||
<p class="settings-scope">Your identity, repository access, and account.</p>
|
||||
<div class="paper-account-card">
|
||||
<img width="40" height="40" class="rounded-circle" alt="" v-if="user?.photo" :src="safeUrl((user?.photo))">
|
||||
<div class="paper-account-identity">
|
||||
@@ -160,11 +90,15 @@ jane.smith@example.org" v-field="{ state: viewState, set: value => { terms =
|
||||
your personal data (username, email, access tokens). This cannot be undone.
|
||||
</p>
|
||||
<div class="paper-inline-alert paper-inline-alert-error" role="alert" v-if="deleteError"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-info"></use></svg> <span v-text="deleteError"></span></div>
|
||||
<button type="button" class="btn btn-outline-danger" @click="deleteAccount()" :disabled="deletingAccount">
|
||||
<button type="button" class="btn btn-outline-danger" @click="confirmDeleteAccount = true" :disabled="deletingAccount">
|
||||
<svg class="ui-icon mr-1" aria-hidden="true"><use href="#ui-icon-trash"></use></svg>{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
|
||||
</button>
|
||||
<div class="settings-delete-confirm" v-if="confirmDeleteAccount">
|
||||
<label for="delete-username">Type {{ user?.username }} to confirm</label>
|
||||
<input id="delete-username" class="form-control" v-model="deleteUsername" autocomplete="off">
|
||||
<div class="settings-actions"><button type="button" class="btn btn-outline-danger" @click="deleteAccount()" :disabled="deletingAccount || deleteUsername !== user?.username">{{ deletingAccount ? 'Deleting…' : 'Delete permanently' }}</button><button type="button" class="btn btn-outline-ink" @click="confirmDeleteAccount = false; deleteUsername = ''" :disabled="deletingAccount">Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
+23
-50
@@ -1,21 +1,16 @@
|
||||
<div class="container paper-page status-page">
|
||||
<div class="paper-crumbs">Anonymization / <span class="here">Status</span></div>
|
||||
<div class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
|
||||
<div>
|
||||
<h1 class="paper-page-title">Status of <em>{{ repoId }}</em></h1>
|
||||
<p class="paper-page-lede">Track progress as your anonymization is prepared.</p>
|
||||
</div>
|
||||
<span class="status-pill" :class="{'status-pill-ready': repo?.status == 'ready', 'status-pill-error': repo?.status == 'error', 'status-pill-removed': repo?.status == 'removed' || repo?.status == 'expired', 'status-pill-ratelimit': rateLimitResetAt}">
|
||||
<span class="status-dot" :class="{'status-ready': repo?.status == 'ready', 'status-error': repo?.status == 'error', 'status-removed': repo?.status == 'removed' || repo?.status == 'expired', 'status-ratelimit': rateLimitResetAt}"></span>
|
||||
<span v-if="!rateLimitResetAt" v-text="fmt?.title(repo?.status)"></span>
|
||||
<span v-if="rateLimitResetAt">Rate limited</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="repository-task status-page">
|
||||
<div class="paper-crumbs"><a href="/dashboard">My work</a> / Repository</div>
|
||||
<h1>Prepare your anonymous review link</h1>
|
||||
<p class="task-lede">{{ repo?.status === 'ready' ? 'Your repository is ready to share.' : 'Your settings are saved. Track preparation before sharing the link.' }}</p>
|
||||
<ol class="task-trail" aria-label="Repository progress"><li>1. Repository</li><li>2. Review and hosting</li><li aria-current="step">3. Review link</li></ol>
|
||||
<div class="task-columns">
|
||||
<section class="task-preparation" aria-label="Repository preparation">
|
||||
<div class="task-ready" v-if="repo?.status === 'ready'" role="status"><strong>Your review link is ready</strong></div>
|
||||
<h2>{{ repoId }}</h2>
|
||||
<p v-if="repo?.status === 'ready'">Share the link with reviewers. They do not need to sign in.</p>
|
||||
<p v-if="error" class="text-danger" role="alert">Unable to load repository status. <button type="button" class="task-text-button" @click="getStatus()">Try again</button></p>
|
||||
|
||||
<section class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Progress</div>
|
||||
|
||||
<p class="paper-section-copy">
|
||||
<p class="paper-section-copy" v-if="repo?.status !== 'ready'">
|
||||
The repository will take a few minutes to get ready, depending on its
|
||||
size. Visit the <a href="/faq">FAQ</a> for more information.
|
||||
</p>
|
||||
@@ -31,7 +26,7 @@
|
||||
<p class="paper-error-msg">Anonymization will resume automatically in <strong>{{ rateLimitCountdown }}</strong>. No action needed — the job is queued and will continue where it left off.</p>
|
||||
</div>
|
||||
|
||||
<div class="paper-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" v-if="repo?.status != 'error' && !rateLimitResetAt" :class="{'paper-progress-ready': repo?.status == 'ready'}" :aria-valuenow="(progress)">
|
||||
<div class="paper-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" v-if="repo?.status !== 'ready' && repo?.status != 'error' && !rateLimitResetAt" :class="{'paper-progress-ready': repo?.status == 'ready'}" :aria-valuenow="(progress)">
|
||||
<div class="paper-progress-bar" :style=""width: " + (progress) + "%;""></div>
|
||||
<div class="paper-progress-label">
|
||||
<span v-text="fmt?.title(repo?.status)"></span><span v-if="repo?.statusMessage"> · <span v-text="repo?.statusMessage"></span></span>
|
||||
@@ -49,6 +44,7 @@
|
||||
</div>
|
||||
<p class="paper-error-msg" v-if="repo?.statusMessage">{{ fmt?.translate('ERRORS.' + repo?.statusMessage) }}</p>
|
||||
<p class="paper-error-msg" v-if="!repo?.statusMessage">No additional details were reported. The most common causes are private repositories, missing branches, and rate limits.</p>
|
||||
<p v-if="repo?.statusMessage === 'quota_exceeded'"><a :href="safeUrl('/anonymize/' + repoId)">Review this repository’s hosting</a>, then retry preparation.</p>
|
||||
<ul class="paper-error-hints">
|
||||
<li>Make sure the source URL points to a repository or pull request you can access.</li>
|
||||
<li>Check that the chosen branch and commit still exist on GitHub.</li>
|
||||
@@ -61,10 +57,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paper-detail-grid">
|
||||
<div class="task-result" v-if="repo?.status === 'ready'">
|
||||
<div class="detail-label">Repository</div>
|
||||
<div class="detail-value">
|
||||
<a target="_self" :href="safeUrl("/r/" + (repoId) + "/")">/r/{{ repoId }}/</a>
|
||||
<a target="_self" :href="safeUrl("/r/" + (repoId) + "/")">{{ reviewUrl }}</a>
|
||||
</div>
|
||||
<div class="detail-label" v-if="repo?.options?.page">GitHub Page</div>
|
||||
<div class="detail-value" v-if="repo?.options?.page">
|
||||
@@ -72,7 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="anonymize-submit-bar" v-if="repo?.status == 'ready'">
|
||||
<div class="task-actions" v-if="repo?.status == 'ready'">
|
||||
<a class="btn btn-ink" target="_self" :href="safeUrl("/r/" + (repoId) + "/")">
|
||||
<svg class="ui-icon mr-1" aria-hidden="true"><use href="#project-icon-eye"></use></svg> Go to anonymized repository
|
||||
</a>
|
||||
@@ -82,33 +78,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Support Anonymous GitHub</div>
|
||||
<p class="paper-section-copy">
|
||||
A small team keeps this running. If it helps you, please consider
|
||||
contributing back — in code, ideas, or coffee.
|
||||
</p>
|
||||
|
||||
<div class="paper-support-grid">
|
||||
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/" target="_blank" rel="noopener">
|
||||
<div class="paper-support-eyebrow"><svg class="ui-icon" aria-hidden="true"><use href="#project-type-repo"></use></svg> Contribute</div>
|
||||
<p>Collaborate by implementing new features and fixing bugs. Help with new file formats or deployment is welcome.</p>
|
||||
<span class="paper-support-cta">Open on GitHub <svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-arrow-right"></use></svg></span>
|
||||
</a>
|
||||
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">
|
||||
<div class="paper-support-eyebrow"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-comment"></use></svg> Feedback</div>
|
||||
<p>Tell us about bugs and missing features in Anonymous GitHub itself. Questions about an anonymized repository go to its authors.</p>
|
||||
<span class="paper-support-cta">File an issue <svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-arrow-right"></use></svg></span>
|
||||
</a>
|
||||
<a class="paper-support-card" href="https://github.com/sponsors/tdurieux/" target="_blank" rel="noopener">
|
||||
<div class="paper-support-eyebrow"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-heart"></use></svg> Sponsor</div>
|
||||
<p>Server costs hover around $25 a month. Any contribution helps keep the lights on.</p>
|
||||
<span class="paper-support-cta">GitHub Sponsors <svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-arrow-right"></use></svg></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="paper-kofi-wrap">
|
||||
<iframe id="kofiframe" src="https://ko-fi.com/tdurieux/?hidefeed=true&widget=true&embed=true&preview=true" title="tdurieux" loading="lazy"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="task-summary" aria-label="Repository summary">
|
||||
<h3>{{ repoId }}</h3>
|
||||
<dl><dt>Source</dt><dd>{{ repo?.source?.fullName ? 'https://github.com/' + repo.source.fullName : 'Loading…' }}</dd><dt>Lifetime</dt><dd>{{ repo?.options?.expirationMode === 'never' ? 'Never expire while subscribed' : repo?.options?.expirationDate ? 'Until ' + fmt.date(repo.options.expirationDate) : 'Loading…' }}</dd><dt>Review access</dt><dd>Anonymous and free</dd></dl>
|
||||
<div class="task-summary-foot"><strong>{{ repo?.status === 'ready' ? 'Ready to share' : 'Repository preparation' }}</strong><p>Coauthors do not each need a subscription.</p></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
+142
-9
@@ -1,3 +1,4 @@
|
||||
import { setupBilling, readBilling } from "./billing.js";
|
||||
import { reactive, nextTick } from "vue";
|
||||
import { createTimers, createListeners } from "./state.js";
|
||||
import { formDraft } from "./drafts.js";
|
||||
@@ -16,6 +17,19 @@ function previewTabKeydown(event) {
|
||||
// Page setup functions run inside Vue effect scopes.
|
||||
export const mainController = function (state, http, location, timeout) {
|
||||
state.previewTabKeydown = previewTabKeydown;
|
||||
state.headerCanUpgrade = false;
|
||||
state.headerBillingPending = ["success", "portal"].includes(new URLSearchParams(window.location.search).get("billing"));
|
||||
const updateHeaderBilling = billing => {
|
||||
const subscribed = (billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId) && ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
||||
state.headerCanUpgrade = !!billing?.enabled && billing.checkoutEnabled !== false && !subscribed && (billing.plans || []).some(p => ["author_monthly", "author_annual"].includes(p.id));
|
||||
};
|
||||
state.on("billing:updated", (_, billing) => updateHeaderBilling(billing));
|
||||
state.on("billing:pending", (_, pending) => { state.headerBillingPending = pending; });
|
||||
state.openHeaderUpgrade = () => {
|
||||
if (location.path() === "/profile") state.emit("settings:upgrade");
|
||||
else location.url("/profile?upgrade=1");
|
||||
};
|
||||
|
||||
state.title = "Main";
|
||||
state.user = { status: "connection" };
|
||||
state.site_options;
|
||||
@@ -78,6 +92,7 @@ export const mainController = function (state, http, location, timeout) {
|
||||
http.get("/api/user").then(
|
||||
(res) => {
|
||||
if (res) state.user = res.data;
|
||||
if (state.user?.username) readBilling(http).then(res => updateHeaderBilling(res.data)).catch(() => {});
|
||||
},
|
||||
() => {
|
||||
state.user = null;
|
||||
@@ -142,6 +157,26 @@ export const faqController = function (state, http) {
|
||||
};
|
||||
|
||||
export const profileController = function (state, http, translate, timeout, quotaService) {
|
||||
state.settingsTab = /(?:billing|upgrade)=/.test(window.location.search) ? "billing" : "defaults";
|
||||
state.confirmDeleteAccount = false;
|
||||
state.deleteUsername = "";
|
||||
state.profilePastSubscription = () => !state.profileAuthorGrant() && (state.billing?.grants || []).some(g => ["author_monthly", "author_annual"].includes(g.planId));
|
||||
state.showAuthorPlans = new URLSearchParams(window.location.search).get("upgrade") === "1";
|
||||
state.showRepositoryPass = false;
|
||||
state.profilePlan = "author_monthly";
|
||||
state.openProfileUpgrade = () => {
|
||||
state.settingsTab = "billing";
|
||||
state.showAuthorPlans = true;
|
||||
nextTick(() => document.getElementById("settings-plan-comparison")?.focus());
|
||||
};
|
||||
state.on("settings:upgrade", () => state.openProfileUpgrade());
|
||||
state.profileAuthorPlans = () => (state.billing?.plans || []).filter(p => ["author_monthly", "author_annual"].includes(p.id));
|
||||
state.profileSelectedPlan = () => state.profileAuthorPlans().find(p => p.id === state.profilePlan) || state.profileAuthorPlans()[0];
|
||||
state.profilePassPlan = () => (state.billing?.plans || []).find(p => p.id === "author_pass");
|
||||
state.profileAuthorGrant = () => (state.billing?.grants || []).find(g => ["author_monthly", "author_annual"].includes(g.planId) && ["active", "paid", "past_due"].includes(g.status) && new Date(g.until).getTime() > Date.now());
|
||||
state.profileGrantName = grant => ({ author_monthly: "Author monthly", author_annual: "Author annual", author_pass: "Repository pass", conference: "Conference hosting" }[grant.planId] || grant.planId);
|
||||
|
||||
setupBilling(state, http, () => quotaService.load().then(quota => { state.quota = quota; }, console.error));
|
||||
state.terms = "";
|
||||
state.options = {
|
||||
expirationMode: "remove",
|
||||
@@ -212,12 +247,7 @@ export const profileController = function (state, http, translate, timeout, quot
|
||||
};
|
||||
|
||||
state.deleteAccount = () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Delete your account? All your anonymized repositories, gists, and pull requests will be removed, and your personal data will be erased. This cannot be undone."
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (state.deletingAccount || !state.confirmDeleteAccount || state.deleteUsername !== state.user?.username) return;
|
||||
state.deletingAccount = true;
|
||||
http.delete("/api/user").then(
|
||||
() => {
|
||||
@@ -999,6 +1029,7 @@ export const prDashboardController = function (state, location) {
|
||||
export const statusController = function (state, http, params) {
|
||||
const timers = createTimers();
|
||||
state.repoId = params.repoId;
|
||||
state.reviewUrl = window.location.origin + "/r/" + encodeURIComponent(params.repoId) + "/";
|
||||
state.repo = null;
|
||||
state.progress = 0;
|
||||
state.rateLimitResetAt = 0;
|
||||
@@ -1127,6 +1158,72 @@ export const anonymizeController = function (state, http, html, params, location
|
||||
? "gist:" + parsed.gistId.toLowerCase()
|
||||
: (parsed.owner + "/" + parsed.repo).toLowerCase() + (parsed.pullRequestId ? "/pull/" + parsed.pullRequestId : "");
|
||||
const draft = formDraft(state, ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"], "anonymize:" + (location.path?.() || ""));
|
||||
let checkoutDraftId = null;
|
||||
let checkoutDraftSnapshot = null;
|
||||
state.hostingReview = false;
|
||||
state.previewExpanded = false;
|
||||
state.taskValidationError = "";
|
||||
state.editRepository = () => {
|
||||
state.mobilePane = "settings";
|
||||
state.previewExpanded = false;
|
||||
nextTick(() => document.querySelector(".anonymize-fields .is-invalid")?.focus());
|
||||
};
|
||||
state.submitRepository = (event) => {
|
||||
state.taskValidationError = "";
|
||||
if (state.sourceLoading || state.billingBusy || state.hostingSaving || state.billingPending) return;
|
||||
if (expirationDateInvalid() || state.anonymize?.invalid || state.sourceUrlError) {
|
||||
state.taskValidationError = "Check the highlighted fields before continuing.";
|
||||
state.editRepository();
|
||||
return;
|
||||
}
|
||||
if (state.hostingNeedsPayment()) return state.checkoutRepository();
|
||||
return state.anonymizeRepo(event);
|
||||
};
|
||||
state.hostingPayment = false;
|
||||
state.hostingAlternatives = false;
|
||||
state.hostingPrice = plan => {
|
||||
if (plan.amount == null) return "Price shown at checkout";
|
||||
const amount = new Intl.NumberFormat(undefined, { style: "currency", currency: plan.currency, minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(plan.amount / 100);
|
||||
return amount + (plan.interval === "year" ? " billed annually" : " billed monthly");
|
||||
};
|
||||
state.hostingPlan = "author_monthly";
|
||||
state.hostingSaving = false;
|
||||
state.selectedHostingPlan = () => state.authorPlans().find(p => p.id === state.hostingPlan) || state.authorPlans()[0];
|
||||
const loadHostingQuota = () => http.get("/api/user/quota").then(res => { state.hostingQuota = res.data; });
|
||||
setupBilling(state, http, async () => {
|
||||
await loadHostingQuota();
|
||||
state.hostingQuotaBlocked = false;
|
||||
}, { confirmedMessage: "Hosting status refreshed. Your draft is ready to continue." });
|
||||
loadHostingQuota().catch(() => {});
|
||||
state.authorPlans = () => (state.billing?.plans || []).filter(p => ["author_monthly", "author_annual"].includes(p.id));
|
||||
state.hasAuthorHosting = () => ["author_monthly", "author_annual"].includes(state.billing?.allowance?.plan);
|
||||
state.hostingNeedsPayment = () => {
|
||||
if (!state.billing?.enabled || state.detectedType !== "repo" || state.hasAuthorHosting() ||
|
||||
(state.isUpdate && state.role !== "owner")) return false;
|
||||
const needsLifetime = state.options?.expirationMode === "never" && !state._existingNever;
|
||||
const quota = state.hostingQuota?.repository;
|
||||
const full = !state.isUpdate && !state.conference && quota && quota.used >= quota.total;
|
||||
return !!(needsLifetime || (!state.conference && (full || state.hostingQuotaBlocked)));
|
||||
};
|
||||
state.checkoutRepository = async (pass = false) => {
|
||||
if (state.hostingSaving || state.billingBusy || !state.billing?.csrf || state.sourceLoading) return;
|
||||
state.hostingSaving = true;
|
||||
state.billingError = "";
|
||||
try {
|
||||
const values = draft.checkpoint();
|
||||
const serialized = JSON.stringify(values);
|
||||
if (!checkoutDraftId || serialized !== checkoutDraftSnapshot) {
|
||||
const saved = await http.post("/api/billing/draft", { path: location.path(), values }, { headers: { "x-csrf-token": state.billing.csrf } });
|
||||
checkoutDraftId = saved.data.id;
|
||||
checkoutDraftSnapshot = serialized;
|
||||
}
|
||||
const plan = state.authorPlans().find(p => p.id === state.hostingPlan) || state.authorPlans()[0];
|
||||
if (!pass && !plan) throw new Error("No author plan available");
|
||||
await state.checkout(pass ? "author_pass" : plan.id, pass ? state.repoId : "", { draftId: checkoutDraftId });
|
||||
} catch { state.billingError = "Your draft could not be saved for checkout. Your form is unchanged. Please try again."; }
|
||||
finally { state.hostingSaving = false; }
|
||||
};
|
||||
state.useDatedHosting = () => { state.options.expirationMode = "remove"; };
|
||||
state.discardDraft = async () => {
|
||||
draft.discard();
|
||||
state.workspaceOpen = !!state.sourceUrl;
|
||||
@@ -1292,6 +1389,26 @@ export const anonymizeController = function (state, http, html, params, location
|
||||
}
|
||||
|
||||
async function restoreGitHubDraft() {
|
||||
const billingDraft = new URLSearchParams(window.location.search).get("billingDraft");
|
||||
if (billingDraft) {
|
||||
draft.restore();
|
||||
try {
|
||||
const saved = (await http.get("/api/billing/draft/" + encodeURIComponent(billingDraft))).data;
|
||||
if (saved.path !== location.path()) throw new Error("Wrong task");
|
||||
for (const key of ["sourceUrl", "terms", "repoId", "source", "options", "conference", "githubConnection"]) {
|
||||
if (Object.prototype.hasOwnProperty.call(saved.values, key)) state[key] = saved.values[key];
|
||||
}
|
||||
if (state.options?.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate);
|
||||
checkoutDraftId = saved.id;
|
||||
checkoutDraftSnapshot = JSON.stringify(draft.checkpoint());
|
||||
state.draftRestored = true;
|
||||
state.hostingReview = true;
|
||||
if (state.sourceUrl) await refreshGitHubAccess();
|
||||
// Consume the return token so revisiting the form cannot overwrite newer edits.
|
||||
location.replace?.(location.path());
|
||||
return true;
|
||||
} catch { state.billingError = "The checkout draft could not be restored. Retry by reloading this page; any local draft is still available."; }
|
||||
}
|
||||
let saved;
|
||||
try { saved = JSON.parse(sessionStorage.getItem("github-access-draft") || "null"); }
|
||||
catch (_) { return false; }
|
||||
@@ -1325,6 +1442,7 @@ export const anonymizeController = function (state, http, html, params, location
|
||||
state.terms = res.data.options.terms.filter((f) => f).join("\n");
|
||||
state.source = res.data.source;
|
||||
state.role = res.data.role || "owner";
|
||||
state._existingNever = res.data.options.expirationMode === "never";
|
||||
state.coauthors = res.data.coauthors || [];
|
||||
// Remember the saved branch so the source.branch watcher knows
|
||||
// not to bump source.commit to GitHub HEAD on edit-page load
|
||||
@@ -1925,6 +2043,7 @@ export const anonymizeController = function (state, http, html, params, location
|
||||
// When the picked date fails min/max validation AngularJS sets the model
|
||||
// to undefined, so we check both the field validity and the model value.
|
||||
function expirationDateInvalid() {
|
||||
if (state.detectedType === "repo" && state.options.expirationMode === "never") return false;
|
||||
const field = state.anonymize && state.anonymize.expirationDate;
|
||||
if (!state.options.expirationDate || (field && field.invalid)) {
|
||||
if (field && field.setDirty) field.setDirty();
|
||||
@@ -2023,11 +2142,18 @@ export const anonymizeController = function (state, http, html, params, location
|
||||
state.anonymizeRepo = (event) => {
|
||||
if (state.sourceLoading) { state.error = "Wait for the source to finish loading before saving."; return; }
|
||||
if (state.reconnectingSource && !state.reconnectRepositoryId) return;
|
||||
if (expirationDateInvalid()) return;
|
||||
if (expirationDateInvalid()) { state.editRepository(); return; }
|
||||
if (state.anonymize?.invalid || state.sourceUrlError) {
|
||||
state.editRepository();
|
||||
state.error = "Please correct the highlighted fields before saving.";
|
||||
return;
|
||||
}
|
||||
// Capacity is enforced on the server, which can release removed reservations.
|
||||
if (state.hostingNeedsPayment() && state.options.expirationMode === "never") {
|
||||
state.hostingReview = true;
|
||||
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "center" }));
|
||||
return;
|
||||
}
|
||||
const button = event.currentTarget || event.target.closest?.("button") || event.target;
|
||||
button.disabled = true;
|
||||
const o = parseGithubUrl(state.sourceUrl);
|
||||
@@ -2051,6 +2177,12 @@ export const anonymizeController = function (state, http, html, params, location
|
||||
if (error.data) {
|
||||
translate("ERRORS." + error.data.error).then((t) => { state.error = t; }, console.error);
|
||||
displayErrorMessage(error.data.error);
|
||||
if (["quota_exceeded", "subscription_required_for_never_expiration"].includes(error.data.error)) {
|
||||
if (error.data.error === "subscription_required_for_never_expiration") state._existingNever = false;
|
||||
state.hostingQuotaBlocked = true;
|
||||
state.hostingReview = true;
|
||||
nextTick(() => document.getElementById("repository-hosting")?.scrollIntoView({ block: "center" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
).finally(() => { button.disabled = false; });
|
||||
@@ -3003,7 +3135,7 @@ export const newConferenceController = function (state, http, location, params)
|
||||
state.error = "";
|
||||
|
||||
function getConference() {
|
||||
http
|
||||
return http
|
||||
.get("/api/conferences/" + params.conferenceId)
|
||||
.then((res) => {
|
||||
state.options = { ...state.options, ...res.data, billing: res.data.billing || {} };
|
||||
@@ -3030,7 +3162,7 @@ export const newConferenceController = function (state, http, location, params)
|
||||
start.setDate(1);
|
||||
start.setMonth(start.getMonth() + 1);
|
||||
const end = new Date(start);
|
||||
end.setMonth(start.getMonth() + 7, 0);
|
||||
end.setTime(start.getTime() + 179 * 86400000);
|
||||
state.options = {
|
||||
billing: {},
|
||||
startDate: start,
|
||||
@@ -3142,6 +3274,7 @@ export const newConferenceController = function (state, http, location, params)
|
||||
};
|
||||
|
||||
export const conferenceController = function (state, http, location, params) {
|
||||
setupBilling(state, http, () => getConference(), { confirmedMessage: "Payment status checked. Your conference is ready for submissions once payment is confirmed." });
|
||||
state.watch("user.status", () => {
|
||||
if (state.user == null) {
|
||||
location.url("/");
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Share concurrent catalog reads between the app header and the current page.
|
||||
const billingReads = new WeakMap();
|
||||
export function readBilling(http) {
|
||||
if (!billingReads.has(http)) {
|
||||
const request = http.get("/api/billing/").finally(() => billingReads.delete(http));
|
||||
billingReads.set(http, request);
|
||||
}
|
||||
return billingReads.get(http);
|
||||
}
|
||||
export function setupBilling(state, http, onRefresh = () => {}, context = {}) {
|
||||
const outcome = new URLSearchParams(window.location.search).get("billing");
|
||||
state.billing = null;
|
||||
state.billingLoading = true;
|
||||
state.billingBusy = false;
|
||||
state.billingError = "";
|
||||
state.billingNotice = "";
|
||||
state.billingPending = outcome === "success" || outcome === "portal";
|
||||
state.billingRepository = "";
|
||||
const messages = {
|
||||
billing_invalid_draft: "This draft could not be saved. Your form is unchanged. Try again.",
|
||||
billing_draft_not_found: "This checkout draft is unavailable or expired. Your local form is unchanged.",
|
||||
billing_recovery_required: "Your earlier payment needs verification. Please contact support before paying again.",
|
||||
billing_checkout_disabled: "New purchases are temporarily unavailable. You can still manage existing billing.",
|
||||
billing_unavailable: "Payments are not available yet. Please try again later.",
|
||||
billing_subscription_exists: "You already have a subscription. Use Manage billing to change or cancel it.",
|
||||
billing_checkout_in_progress: "You have another checkout open. Complete it or wait for it to expire before choosing a different plan.",
|
||||
billing_busy: "Another billing request is processing. Please try again shortly.",
|
||||
billing_already_paid: "This repository or conference already has an active paid package.",
|
||||
billing_invalid_review_window: "Conference packages cover up to 180 days. Check the review dates.",
|
||||
billing_invalid_conference: "Only a conference owner can pay for a conference awaiting payment.",
|
||||
repo_not_found: "Enter the ID of an existing repository that you own.",
|
||||
};
|
||||
state.billingPrice = plan => {
|
||||
if (plan.amount == null) return "Price shown at checkout";
|
||||
const amount = new Intl.NumberFormat(undefined, { style: "currency", currency: plan.currency }).format(plan.amount / 100);
|
||||
return amount + (plan.interval ? ` / ${plan.intervalCount > 1 ? plan.intervalCount + " " : ""}${plan.interval}` : " once");
|
||||
};
|
||||
state.billingBytes = value => {
|
||||
const bytes = Number(value) || 0;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const exponent = Math.min(4, Math.floor(Math.log2(bytes) / 10));
|
||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(bytes / 1024 ** exponent)} ${["B", "KiB", "MiB", "GiB", "TiB"][exponent]}`;
|
||||
};
|
||||
state.billingDate = value => value ? new Date(value).toLocaleDateString() : "";
|
||||
state.loadBilling = () => { state.billingError = ""; state.billingLoading = true; return readBilling(http).then(res => { state.billing = res.data; state.emit?.("billing:updated", res.data); })
|
||||
.catch(() => { state.billingError = "Billing could not be loaded. Try again."; })
|
||||
.finally(() => { state.billingLoading = false; }); };
|
||||
async function action(path, body, redirect = true) {
|
||||
if (state.billingBusy || !state.billing?.csrf) return;
|
||||
state.billingBusy = true;
|
||||
state.billingError = "";
|
||||
try {
|
||||
const response = await http.post(`/api/billing/${path}`, body, { headers: { "x-csrf-token": state.billing.csrf } });
|
||||
if (redirect) window.location.assign(response.data.url);
|
||||
else {
|
||||
await state.loadBilling();
|
||||
await onRefresh();
|
||||
state.billingPending = !!response.data.pending;
|
||||
state.emit?.("billing:pending", state.billingPending);
|
||||
state.billingNotice = response.data.pending ? "Your payment is still pending. Please do not pay again." : (context.confirmedMessage || "Billing status refreshed. Your confirmed access appears below.");
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) { state.billingError = messages[error.data?.error] || "The billing request failed. Please try again."; }
|
||||
finally { state.billingBusy = false; }
|
||||
}
|
||||
state.checkout = (planId, target = "", extra = {}) => action("checkout", { planId, target, ...extra });
|
||||
state.billingPortal = () => action("portal", {});
|
||||
state.refreshBilling = () => action("refresh", {}, false);
|
||||
async function confirmPayment() {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const result = await state.refreshBilling();
|
||||
if (!result || !result.pending) return;
|
||||
await new Promise(resolve => window.setTimeout(resolve, 3000));
|
||||
}
|
||||
state.billingNotice = "Confirmation is taking longer than usual. You can refresh payment status later. Please do not pay again.";
|
||||
}
|
||||
state.loadBilling().then(() => {
|
||||
if (outcome === "success" || outcome === "portal") {
|
||||
state.billingNotice = "Checking your payment. Paid access starts after confirmation.";
|
||||
confirmPayment();
|
||||
} else if (outcome === "cancelled") state.billingNotice = "Checkout was closed. No new paid access has been activated.";
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const QuotaUsage = {
|
||||
props: ["quota", "fmt"],
|
||||
setup(props) {
|
||||
return { formatAmount: (value, kind) => kind === "bytes"
|
||||
? props.fmt.humanFileSize(value).replace(/([a-zA-Z]+)$/, " $1") : props.fmt.number(value) };
|
||||
? props.fmt.humanFileSize(props.quota?.storage?.unit === "bytes" ? value * 8 : value).replace(/([a-zA-Z]+)$/, " $1") : props.fmt.number(value) };
|
||||
},
|
||||
render: quotaUsageTemplate,
|
||||
};
|
||||
|
||||
@@ -49,7 +49,12 @@ export function formDraft(state, fields, key) {
|
||||
state.unsavedChanges = serialize() !== baseline;
|
||||
return !!restored;
|
||||
},
|
||||
checkpoint() { return JSON.parse(serialize()); },
|
||||
checkpoint() {
|
||||
document.querySelectorAll("input, select, textarea").forEach(el => {
|
||||
if (el._field?.binding.state === state && el._field.timer) el._field.commit();
|
||||
});
|
||||
return JSON.parse(serialize());
|
||||
},
|
||||
accept(saved) {
|
||||
baseline = JSON.stringify(saved);
|
||||
state.draftRestored = false;
|
||||
|
||||
@@ -92,7 +92,7 @@ export function submitForm(event, callback) {
|
||||
if (el._field) validate(el);
|
||||
});
|
||||
if (form && !form.checkValidity()) {
|
||||
const invalid = form.querySelector(":invalid");
|
||||
const invalid = form.querySelector("input:invalid, select:invalid, textarea:invalid");
|
||||
invalid?.scrollIntoView?.({ block: "center" });
|
||||
invalid?.focus();
|
||||
form.reportValidity();
|
||||
|
||||
@@ -44,6 +44,7 @@ export function mountApplication(target = "#app", options = {}) {
|
||||
const timers = createTimers();
|
||||
const location = {
|
||||
url(value) { if (value === undefined) return router.currentRoute.value.fullPath; router.push(value); return location; },
|
||||
replace(value) { return router.replace(value); },
|
||||
path() { return router.currentRoute.value.path; },
|
||||
search() { return router.currentRoute.value.query; },
|
||||
};
|
||||
|
||||
Vendored
+23
-23
@@ -1,30 +1,30 @@
|
||||
"use strict";(()=>{var nd=Object.defineProperty;var rd=(e,t)=>{for(var n in t)nd(e,n,{get:t[n],enumerable:!0})};var he=["abap","abc","actionscript","ada","alda","apache_conf","apex","applescript","aql","asciidoc","asl","assembly_x86","autohotkey","batchfile","c9search","c_cpp","cirru","clojure","cobol","coffee","coldfusion","crystal","csharp","csound_document","csound_orchestra","csound_score","csp","css","curly","d","dart","diff","django","dockerfile","dot","drools","edifact","eiffel","ejs","elixir","elm","erlang","forth","fortran","fsharp","fsl","ftl","gcode","gherkin","gitignore","glsl","gobstones","golang","graphqlschema","groovy","haml","handlebars","haskell","haskell_cabal","haxe","hjson","html","html_elixir","html_ruby","ini","io","jack","jade","java","javascript","json","json5","jsoniq","jsp","jssm","jsx","julia","kotlin","latex","less","liquid","lisp","livescript","logiql","logtalk","lsl","lua","luapage","lucene","makefile","markdown","mask","matlab","maze","mediawiki","mel","mixal","mushcode","mysql","nginx","nim","nix","nsis","nunjucks","objectivec","ocaml","pascal","perl","perl6","pgsql","php","php_laravel_blade","pig","plain_text","powershell","praat","prisma","prolog","properties","protobuf","puppet","python","qml","r","razor","rdoc","red","redshift","rhtml","rst","ruby","rust","sass","scad","scala","scheme","scss","sh","sjs","slim","smarty","snippets","soy_template","space","sparql","sql","sqlserver","stylus","svg","swift","tcl","terraform","tex","text","textile","toml","tsx","turtle","twig","typescript","vala","vbscript","velocity","verilog","vhdl","visualforce","wollok","xml","xquery","yaml","zeek"];var ve={notebook:"/script/notebook.c3144c18ae.min.js",org:"/script/org.f61f262915.min.js",markdown:"/script/markdown.44f2e93820.min.js",pdf:"/script/pdf.4d9cc6ff49.min.js",editor:"/script/editor.4f142d3adc.min.js"};var cs=new Map,ld=ve;function xt(e){if(!cs.has(e)){let t=document.createElement("script");t.src=ld[e];let n=new Promise((r,s)=>{t.onload=r,t.onerror=()=>{cs.delete(e),t.remove(),s(new Error(`Unable to load ${e}. Please retry.`))}});cs.set(e,n),document.head.appendChild(t)}return cs.get(e)}async function ur(){await xt("editor"),ace.config.set("basePath","/script/external/ace/")}function ho(e){let t=Object.create(null);for(let n of e.split(","))t[n]=1;return n=>n in t}var Ve={},Xt=[],at=()=>{},rn=()=>!1,vo=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),go=e=>e.startsWith("onUpdate:"),Ge=Object.assign,Po=(e,t)=>{let n=e.indexOf(t);n>-1&&e.splice(n,1)},dd=Object.prototype.hasOwnProperty,De=(e,t)=>dd.call(e,t),ye=Array.isArray,Ut=e=>Vo(e)==="[object Map]",Ft=e=>Vo(e)==="[object Set]",cr=e=>Vo(e)==="[object Date]";var we=e=>typeof e=="function",ze=e=>typeof e=="string",mt=e=>typeof e=="symbol",Te=e=>e!==null&&typeof e=="object",an=e=>(Te(e)||we(e))&&we(e.then)&&we(e.catch),hr=Object.prototype.toString,Vo=e=>hr.call(e),ln=e=>Vo(e).slice(8,-1),ps=e=>Vo(e)==="[object Object]",fs=e=>ze(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,yo=ho(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted");var ms=e=>{let t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},ud=/-\w/g,Je=ms(e=>e.replace(ud,t=>t.slice(1).toUpperCase())),cd=/\B([A-Z])/g,St=ms(e=>e.replace(cd,"-$1").toLowerCase()),eo=ms(e=>e.charAt(0).toUpperCase()+e.slice(1)),qo=ms(e=>e?`on${eo(e)}`:""),yt=(e,t)=>!Object.is(e,t),bo=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},hs=(e,t,n,r=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},wo=e=>{let t=parseFloat(e);return isNaN(t)?e:t};var pr,Mo=()=>pr||(pr=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Me(e){if(ye(e)){let t={};for(let n=0;n<e.length;n++){let r=e[n],s=ze(r)?hd(r):Me(r);if(s)for(let i in s)t[i]=s[i]}return t}else if(ze(e)||Te(e))return e}var pd=/;(?![^(]*\))/g,fd=/:([^]+)/,md=/"(?:[^"\\]|\\[^])*"|'(?:[^'\\]|\\[^])*'|\\[^]|\/\*[^]*?\*\//g;function hd(e){let t={};return e.replace(md,n=>n.startsWith("/*")?"":n).split(pd).forEach(n=>{if(n){let r=n.split(fd);r.length>1&&(t[r[0].trim()]=r[1].trim())}}),t}function T(e){let t="";if(ze(e))t=e;else if(ye(e))for(let n=0;n<e.length;n++){let r=T(e[n]);r&&(t+=r+" ")}else if(Te(e))for(let n in e)e[n]&&(t+=n+" ");return t.trim()}var vr="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",gr=ho(vr),vd=ho(vr+",async,autofocus,autoplay,controls,default,defer,disabled,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");function vs(e){return!!e||e===""}function gd(e,t,n){if(e.length!==t.length)return!1;let r=!0;for(let s=0;r&&s<e.length;s++)r=Nt(e[s],t[s],n);return r}function fr(e,t,n){if(e.size!==t.size)return!1;let r=Array.from(t),s=new Uint8Array(r.length);for(let i of e){let a=-1;for(let u=0;u<r.length;u++)if(!s[u]&&Nt(i,r[u],n)){a=u;break}if(a<0)return!1;s[a]=1Line truncated
|
||||
`))),e[uo]=Ws(s);let i=r||s.props&&s.props.type==="number";lo(e,t?"change":"input",a=>{a.target.composing||e[uo](Hn(e.value,n,i))}),(n||i)&&lo(e,"change",()=>{e.value=Hn(e.value,n,i)}),t||(lo(e,"compositionstart",lc),lo(e,"compositionend",Yi),lo(e,"change",Yi))},mounted(e,{value:t,modifiers:{trim:n,number:r}}){let s=t??"",i=e[js];delete e[js],i!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==i?e[uo](Hn(e.value,n,r)):e.value=s},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:s,number:i}},a){if(e[uo]=Ws(a),e.composing)return;let u=(i||e.type==="number")&&!/^0\d/.test(e.value)?wo(e.value):e.value,f=t??"";if(u===f)return;let h=e.getRootNode();(h instanceof Document||h instanceof ShadowRoot)&&h.activeElement===e&&e.type!=="range"&&(r&&t===n||s&&e.value.trim()===f)||(e.value=f)}};var ea={deep:!0,created(e,{value:t,modifiers:{number:n}},r){e._modelValue=t,lo(e,"change",()=>{let s=Array.prototype.filter.call(e.options,f=>f.selected).map(f=>n?wo(Ks(f)):Ks(f)),i=e.multiple,a=i?Ft(e._modelValue)?new Set(s):s:s[0],u=e._pendingValue=[i,i?ye(a)?s.slice():s:a];try{e[uo](a)}finally{st(()=>{e._pendingValue===u&&(e._pendingValue=void 0)})}}),e[uo]=Ws(r)},mounted(e,{value:t}){Ji(e,t)},beforeUpdate(e,{value:t},n){e._modelValue=t,e[uo]=Ws(n)},updated(e,{value:t}){let n=e._pendingValue;e._pendingValue=void 0,(!n||n[0]!==e.multiple||!dc(t,n[1],n[0]))&&Ji(e,t)}};function dc(e,t,n){if(!n)return Nt(e,t);if(ye(e))return Nt(e,t);if(Ft(e)){if(e.size!==t.length)return!1;for(let r of t)if(!e.has(r))return!1;return!0}return!1}function Ji(e,t){let n=e.multiple,r=ye(t);if(!(n&&!r&&!Ft(t))){for(let s=0,i=e.options.length;s<i;s++){let a=e.options[s],u=Ks(a);if(n)if(r){let f=typeof u;f==="string"||f==="number"?a.selected=t.some(h=>String(h)===String(u)):a.selected=yr(t,u)>-1}else a.selected=t.has(u);else if(Nt(Ks(a),t)){e.selectedIndex!==s&&(e.selectedIndex=s);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Ks(e){return"_value"in e?e._value:e.value}var uc=["ctrl","shift","alt","meta"],cc={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>uc.some(n=>e[`${n}Key`]&&!t.includes(n))},me=(e,t)=>{if(!e)return e;let n=e._withMods||(e._withMods={}),r=t.join(".");return n[r]||(n[r]=((s,...i)=>{for(let a=0;a<t.length;a++){let u=cc[t[a]];if(u&&u(s,t))return}return e(s,...i)}))},pc={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},Ro=(e,t)=>{let n=e._withKeys||(e._withKeys={}),r=t.join(".");return n[r]||(n[r]=(s=>{if(!("key"in s))return;let i=St(s.key);if(t.some(a=>a===i||pc[a]===i))return e(s)}))},fc=Ge({patchProp:rc},Bu),Qi;function mc(){return Qi||(Qi=Ai(fc))}var ta=((...e)=>{let t=mc().createApp(...e),{mount:n}=t;return t.mount=r=>{let s=vc(r);if(!s)return;let i=t._component;!we(i)&&!i.render&&!i.template&&(i.template=s.innerHTML),s.nodeType===1&&(s.textContent="");let a=n(s,!1,hc(s));return s instanceof Element&&(s.removeAttribute("v-cloak"),s.setAttribute("data-v-app","")),a},t});function hc(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function vc(e){return ze(e)?document.querySelector(e):e}var fo=typeof document<"u";function sa(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function gc(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&sa(e.default)}var Oe=Object.assign;function Ys(e,t){let n={};for(let r in t){let s=t[r];n[r]=kt(s)?s.map(e):e(s)}return n}var Ao=()=>{},kt=Array.isArray;function Wn(e,t){let n={};for(let r in e)n[r]=r in t?t[r]:e[r];return n}var na=/#/g,yc=/&/g,bc=/\//g,wc=/=/g,kc=/\?/g,ra=/\+/g,Ec=/%5B/g,Cc=/%5D/g,ia=/%5E/g,_c=/%60/g,aa=/%7B/g,Sc=/%7C/g,la=/%7D/g,Nc=/%20/g;function Kn(e){return e==null?"":encodeURI(""+e).replace(Sc,"|").replace(Ec,"[").replace(Cc,"]")}function da(e){return Kn(e).replace(aa,"{").replace(la,"}").replace(ia,"^")}function jn(e){return Kn(e).replace(ra,"%2B").replace(Nc,"+").replace(na,"%23").replace(yc,"%26").replace(_c,"`").replace(aa,"{").replace(la,"}").replace(ia,"^")}function Dc(e){return jn(e).replace(wc,"%3D")}function Rc(e){return Kn(e).replace(na,"%23").replace(kc,"%3F")}function ua(e){return Rc(e).replace(bc,"%2F")}function To(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}var Tc=/\/$/,Ac=e=>e.replace(Tc,"");function Js(e,t,n="/"){let r,s={},i="",a="",u=t.indexOf("#"),f=t.indexOf("?");return f=u>=0&&f>u?-1:f,f>=0&&(r=t.slice(0,f),i=t.slice(f,u>0?u:t.length),s=e(i.slice(1))),u>=0&&(r=r||t.slice(0,u),a=t.slice(u,t.length)),r=Ic(r??t,n),{fullPath:r+i+a,path:r,query:s,hash:To(a)}}function ca(e,t){let n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Yn(e,t){return!t||!e.toLowerCase().sLine truncated
|
||||
"use strict";(()=>{var ad=Object.defineProperty;var ld=(e,t)=>{for(var s in t)ad(e,s,{get:t[s],enumerable:!0})};var fe=["abap","abc","actionscript","ada","alda","apache_conf","apex","applescript","aql","asciidoc","asl","assembly_x86","autohotkey","batchfile","c9search","c_cpp","cirru","clojure","cobol","coffee","coldfusion","crystal","csharp","csound_document","csound_orchestra","csound_score","csp","css","curly","d","dart","diff","django","dockerfile","dot","drools","edifact","eiffel","ejs","elixir","elm","erlang","forth","fortran","fsharp","fsl","ftl","gcode","gherkin","gitignore","glsl","gobstones","golang","graphqlschema","groovy","haml","handlebars","haskell","haskell_cabal","haxe","hjson","html","html_elixir","html_ruby","ini","io","jack","jade","java","javascript","json","json5","jsoniq","jsp","jssm","jsx","julia","kotlin","latex","less","liquid","lisp","livescript","logiql","logtalk","lsl","lua","luapage","lucene","makefile","markdown","mask","matlab","maze","mediawiki","mel","mixal","mushcode","mysql","nginx","nim","nix","nsis","nunjucks","objectivec","ocaml","pascal","perl","perl6","pgsql","php","php_laravel_blade","pig","plain_text","powershell","praat","prisma","prolog","properties","protobuf","puppet","python","qml","r","razor","rdoc","red","redshift","rhtml","rst","ruby","rust","sass","scad","scala","scheme","scss","sh","sjs","slim","smarty","snippets","soy_template","space","sparql","sql","sqlserver","stylus","svg","swift","tcl","terraform","tex","text","textile","toml","tsx","turtle","twig","typescript","vala","vbscript","velocity","verilog","vhdl","visualforce","wollok","xml","xquery","yaml","zeek"];var he={notebook:"/script/notebook.c3144c18ae.min.js",org:"/script/org.f61f262915.min.js",pdf:"/script/pdf.4d9cc6ff49.min.js",markdown:"/script/markdown.44f2e93820.min.js",editor:"/script/editor.4f142d3adc.min.js"};var fo=new Map,cd=he;function xt(e){if(!fo.has(e)){let t=document.createElement("script");t.src=cd[e];let s=new Promise((i,o)=>{t.onload=i,t.onerror=()=>{fo.delete(e),t.remove(),o(new Error(`Unable to load ${e}. Please retry.`))}});fo.set(e,s),document.head.appendChild(t)}return fo.get(e)}async function mi(){await xt("editor"),ace.config.set("basePath","/script/external/ace/")}function mn(e){let t=Object.create(null);for(let s of e.split(","))t[s]=1;return s=>s in t}var Pe={},Xt=[],at=()=>{},us=()=>!1,vn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),gn=e=>e.startsWith("onUpdate:"),Ge=Object.assign,In=(e,t)=>{let s=e.indexOf(t);s>-1&&e.splice(s,1)},pd=Object.prototype.hasOwnProperty,Re=(e,t)=>pd.call(e,t),ve=Array.isArray,Ut=e=>Vn(e)==="[object Map]",Ft=e=>Vn(e)==="[object Set]",vi=e=>Vn(e)==="[object Date]";var be=e=>typeof e=="function",Be=e=>typeof e=="string",ht=e=>typeof e=="symbol",Ae=e=>e!==null&&typeof e=="object",cs=e=>(Ae(e)||be(e))&&be(e.then)&&be(e.catch),wi=Object.prototype.toString,Vn=e=>wi.call(e),ps=e=>Vn(e).slice(8,-1),ho=e=>Vn(e)==="[object Object]",mo=e=>Be(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,yn=mn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted");var vo=e=>{let t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},fd=/-\w/g,Je=vo(e=>e.replace(fd,t=>t.slice(1).toUpperCase())),hd=/\B([A-Z])/g,St=vo(e=>e.replace(hd,"-$1").toLowerCase()),en=vo(e=>e.charAt(0).toUpperCase()+e.slice(1)),qn=vo(e=>e?`on${en(e)}`:""),yt=(e,t)=>!Object.is(e,t),bn=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},go=(e,t,s,i=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:i,value:s})},wn=e=>{let t=parseFloat(e);return isNaN(t)?e:t};var gi,Mn=()=>gi||(gi=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Ve(e){if(ve(e)){let t={};for(let s=0;s<e.length;s++){let i=e[s],o=Be(i)?yd(i):Ve(i);if(o)for(let r in o)t[r]=o[r]}return t}else if(Be(e)||Ae(e))return e}var md=/;(?![^(]*\))/g,vd=/:([^]+)/,gd=/"(?:[^"\\]|\\[^])*"|'(?:[^'\\]|\\[^])*'|\\[^]|\/\*[^]*?\*\//g;function yd(e){let t={};return e.replace(gd,s=>s.startsWith("/*")?"":s).split(md).forEach(s=>{if(s){let i=s.split(vd);i.length>1&&(t[i[0].trim()]=i[1].trim())}}),t}function A(e){let t="";if(Be(e))t=e;else if(ve(e))for(let s=0;s<e.length;s++){let i=A(e[s]);i&&(t+=i+" ")}else if(Ae(e))for(let s in e)e[s]&&(t+=s+" ");return t.trim()}var ki="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Ei=mn(ki),bd=mn(ki+",async,autofocus,autoplay,controls,default,defer,disabled,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");function yo(e){return!!e||e===""}function wd(e,t,s){if(e.length!==t.length)return!1;let i=!0;for(let o=0;i&&o<e.length;o++)i=Nt(e[o],t[o],s);return i}function yi(e,t,s){if(e.size!==t.size)return!1;let i=Array.from(t),o=new Uint8Array(i.length);for(let r of e){let a=-1;for(let c=0;c<i.length;c++)if(!o[c]&&Nt(r,i[c],s)){a=c;break}if(a<0)return!1;o[a]=1Line truncated
|
||||
`))),e[un]=Yo(o);let r=i||o.props&&o.props.type==="number";dn(e,t?"change":"input",a=>{a.target.composing||e[un](Ws(e.value,s,r))}),(s||r)&&dn(e,"change",()=>{e.value=Ws(e.value,s,r)}),t||(dn(e,"compositionstart",cc),dn(e,"compositionend",xr),dn(e,"change",xr))},mounted(e,{value:t,modifiers:{trim:s,number:i}}){let o=t??"",r=e[Wo];delete e[Wo],r!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==r?e[un](Ws(e.value,s,i)):e.value=o},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:i,trim:o,number:r}},a){if(e[un]=Yo(a),e.composing)return;let c=(r||e.type==="number")&&!/^0\d/.test(e.value)?wn(e.value):e.value,f=t??"";if(c===f)return;let v=e.getRootNode();(v instanceof Document||v instanceof ShadowRoot)&&v.activeElement===e&&e.type!=="range"&&(i&&t===s||o&&e.value.trim()===f)||(e.value=f)}};var Jo={deep:!0,created(e,{value:t,modifiers:{number:s}},i){e._modelValue=t,dn(e,"change",()=>{let o=Array.prototype.filter.call(e.options,f=>f.selected).map(f=>s?wn(Qo(f)):Qo(f)),r=e.multiple,a=r?Ft(e._modelValue)?new Set(o):o:o[0],c=e._pendingValue=[r,r?ve(a)?o.slice():o:a];try{e[un](a)}finally{Ye(()=>{e._pendingValue===c&&(e._pendingValue=void 0)})}}),e[un]=Yo(i)},mounted(e,{value:t}){ea(e,t)},beforeUpdate(e,{value:t},s){e._modelValue=t,e[un]=Yo(s)},updated(e,{value:t}){let s=e._pendingValue;e._pendingValue=void 0,(!s||s[0]!==e.multiple||!pc(t,s[1],s[0]))&&ea(e,t)}};function pc(e,t,s){if(!s)return Nt(e,t);if(ve(e))return Nt(e,t);if(Ft(e)){if(e.size!==t.length)return!1;for(let i of t)if(!e.has(i))return!1;return!0}return!1}function ea(e,t){let s=e.multiple,i=ve(t);if(!(s&&!i&&!Ft(t))){for(let o=0,r=e.options.length;o<r;o++){let a=e.options[o],c=Qo(a);if(s)if(i){let f=typeof c;f==="string"||f==="number"?a.selected=t.some(v=>String(v)===String(c)):a.selected=Ci(t,c)>-1}else a.selected=t.has(c);else if(Nt(Qo(a),t)){e.selectedIndex!==o&&(e.selectedIndex=o);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Qo(e){return"_value"in e?e._value:e.value}var fc=["ctrl","shift","alt","meta"],hc={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>fc.some(s=>e[`${s}Key`]&&!t.includes(s))},ue=(e,t)=>{if(!e)return e;let s=e._withMods||(e._withMods={}),i=t.join(".");return s[i]||(s[i]=((o,...r)=>{for(let a=0;a<t.length;a++){let c=hc[t[a]];if(c&&c(o,t))return}return e(o,...r)}))},mc={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},Rn=(e,t)=>{let s=e._withKeys||(e._withKeys={}),i=t.join(".");return s[i]||(s[i]=(o=>{if(!("key"in o))return;let r=St(o.key);if(t.some(a=>a===r||mc[a]===r))return e(o)}))},vc=Ge({patchProp:lc},Wu),ta;function gc(){return ta||(ta=qr(vc))}var sa=((...e)=>{let t=gc().createApp(...e),{mount:s}=t;return t.mount=i=>{let o=bc(i);if(!o)return;let r=t._component;!be(r)&&!r.render&&!r.template&&(r.template=o.innerHTML),o.nodeType===1&&(o.textContent="");let a=s(o,!1,yc(o));return o instanceof Element&&(o.removeAttribute("v-cloak"),o.setAttribute("data-v-app","")),a},t});function yc(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function bc(e){return Be(e)?document.querySelector(e):e}var fn=typeof document<"u";function ra(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function wc(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&ra(e.default)}var Oe=Object.assign;function Xo(e,t){let s={};for(let i in t){let o=t[i];s[i]=kt(o)?o.map(e):e(o)}return s}var An=()=>{},kt=Array.isArray;function Js(e,t){let s={};for(let i in e)s[i]=i in t?t[i]:e[i];return s}var aa=/#/g,kc=/&/g,Ec=/\//g,Cc=/=/g,_c=/\?/g,la=/\+/g,Sc=/%5B/g,Nc=/%5D/g,da=/%5E/g,Dc=/%60/g,ua=/%7B/g,Rc=/%7C/g,ca=/%7D/g,Tc=/%20/g;function Xs(e){return e==null?"":encodeURI(""+e).replace(Rc,"|").replace(Sc,"[").replace(Nc,"]")}function pa(e){return Xs(e).replace(ua,"{").replace(ca,"}").replace(da,"^")}function Ys(e){return Xs(e).replace(la,"%2B").replace(Tc,"+").replace(aa,"%23").replace(kc,"%26").replace(Dc,"`").replace(ua,"{").replace(ca,"}").replace(da,"^")}function Ac(e){return Ys(e).replace(Cc,"%3D")}function Oc(e){return Xs(e).replace(aa,"%23").replace(_c,"%3F")}function fa(e){return Oc(e).replace(Ec,"%2F")}function Tn(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}var Pc=/\/$/,Ic=e=>e.replace(Pc,"");function Zo(e,t,s="/"){let i,o={},r="",a="",c=t.indexOf("#"),f=t.indexOf("?");return f=c>=0&&f>c?-1:f,f>=0&&(i=t.slice(0,f),r=t.slice(f,c>0?c:t.length),o=e(r.slice(1))),c>=0&&(i=i||t.slice(0,c),a=t.slice(c,t.length)),i=qc(i??t,s),{fullPath:i+r+a,path:i,query:o,hash:Tn(a)}}function ha(e,t){let s=t.query?e(t.query):"";return t.path+(s&&"?")+s+(t.hash||"")}function Zs(e,t){return!t||!e.toLowerCase().sLine truncated
|
||||
${JSON.stringify(e)}${t?`
|
||||
while being at
|
||||
`+JSON.stringify(t):""}`},[je.NAVIGATION_GUARD_REDIRECT]({from:e,to:t}){return`Redirected from "${e.fullPath}" to "${Lc(t)}" via a navigation guard.`},[je.NAVIGATION_ABORTED]({from:e,to:t}){return`Navigation aborted from "${e.fullPath}" to "${t.fullPath}" via a navigation guard.`},[je.NAVIGATION_CANCELLED]({from:e,to:t}){return`Navigation cancelled from "${e.fullPath}" to "${t.fullPath}" with a new navigation.`},[je.NAVIGATION_DUPLICATED]({from:e,to:t}){return`Avoided redundant navigation to current location: "${e.fullPath}".`}};function po(e,t){return Oe(new Error,{type:e,[ya]:!0},t)}function $t(e,t){return e instanceof Error&&ya in e&&(t==null||!!(e.type&t))}var Mc=["params","query","hash"];function Lc(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;let t={};for(let n of Mc)n in e&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function ba(e){let t={};if(e===""||e==="?")return t;let n=(e[0]==="?"?e.slice(1):e).split("&");for(let r=0;r<n.length;++r){let s=n[r].replace(ra," "),i=s.indexOf("="),a=To(i<0?s:s.slice(0,i)),u=i<0?null:To(s.slice(i+1));if(a in t){let f=t[a];kt(f)||(f=t[a]=[f]),f.push(u)}else t[a]=u}return t}function Zn(e){let t="";for(let n in e){let r=e[n];if(n=Dc(n),r==null){r!==void 0&&(t+=(t.length?"&":"")+n);continue}(kt(r)?r.map(s=>s&&jn(s)):[r&&jn(r)]).forEach(s=>{s!==void 0&&(t+=(t.length?"&":"")+n,s!=null&&(t+="="+s))})}return t}function wa(e){let t={};for(let n in e){let r=e[n];r!==void 0&&(t[n]=kt(r)?r.map(s=>s==null?null:""+s):r==null?r:""+r)}return t}var ka=Symbol(""),xn=Symbol(""),Zs=Symbol(""),xs=Symbol(""),en=Symbol("");function Oo(){let e=[];function t(r){return e.push(r),()=>{let s=e.indexOf(r);s>-1&&e.splice(s,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function Yt(e,t,n,r,s,i=a=>a()){let a=r&&(r.enterCallbacks[s]=r.enterCallbacks[s]||[]);return()=>new Promise((u,f)=>{let h=m=>{m===!1?f(po(je.NAVIGATION_ABORTED,{from:n,to:t})):m instanceof Error?f(m):qc(m)?f(po(je.NAVIGATION_GUARD_REDIRECT,{from:t,to:m})):(a&&r.enterCallbacks[s]===a&&typeof m=="function"&&a.push(m),u())},g=i(()=>e.call(r&&r.instances[s],t,n,h)),k=Promise.resolve(g);e.length<3&&(k=k.then(h)),k.catch(m=>f(m))})}function tn(e,t,n,r,s=i=>i()){let i=[];for(let a of e)for(let u in a.components){let f=a.components[u];if(!(t!=="beforeRouteEnter"&&!a.instances[u]))if(sa(f)){let h=(f.__vccOpts||f)[t];h&&i.push(Yt(h,n,r,a,u,s))}else{let h=f();i.push(()=>h.then(g=>{if(!g)throw new Error(`Couldn't resolve component "${u}" at "${a.path}"`);let k=gc(g)?g.default:g;a.mods[u]=g,a.components[u]=k;let m=(k.__vccOpts||k)[t];return m&&Yt(m,n,r,a,u,s)()}))}}return i}function Ea(e,t){let n=[],r=[],s=[],i=Math.max(t.matched.length,e.matched.length);for(let a=0;a<i;a++){let u=t.matched[a];u&&(e.matched.find(h=>co(h,u))?r.push(u):n.push(u));let f=e.matched[a];f&&(t.matched.find(h=>co(h,f))||s.push(f))}return[n,r,s]}var $c=()=>location.protocol+"//"+location.host;function Pa(e,t){let{pathname:n,search:r,hash:s}=t,i=e.indexOf("#");if(i>-1){let a=s.includes(e.slice(i))?e.slice(i).length:1,u=s.slice(a);return u[0]!=="/"&&(u="/"+u),Yn(u,"")}return Yn(n,e)+r+s}function Uc(e,t,n,r){let s=[],i=[],a=null,u=({state:m})=>{let y=Pa(e,location),E=n.value,w=t.value,R=0;if(m){if(n.value=y,t.value=m,a&&a===E){a=null;return}R=w?m.position-w.position:0}else r(y);s.forEach(D=>{D(n.value,E,{delta:R,type:Qs.pop,direction:R?R>0?Xs.forward:Xs.back:Xs.unknown})})};function f(){a=n.value}function h(m){s.push(m);let y=()=>{let E=s.indexOf(m);E>-1&&s.splice(E,1)};return i.push(y),y}function g(){if(document.visibilityState==="hidden"){let{history:m}=window;if(!m.state)return;m.replaceState(Oe({},m.state,{scroll:us()}),"")}}function k(){for(let m of i)m();i=[],window.removeEventListener("popstate",u),window.removeEventListener("pagehide",g),document.removeEventListener("visibilitychange",g)}return window.addEventListener("popstate",u),window.addEventListener("pagehide",g),document.addEventListener("visibilitychange",g),{pauseListeners:f,listen:h,destroy:k}}function Ca(e,t,n,r=!1,s=!1){return{back:e,current:t,forward:n,replaced:r,position:window.history.length,scroll:s?us():null}}function Fc(e){let{history:t,location:n}=window,r={value:Pa(e,n)},s={value:t.state};s.value||i(r.value,{back:null,current:r.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function i(f,h,g){let k=e.indexOf("#"),m=k>-1?(n.host&&document.querySelector("base")?e:e.slice(k))+f:$c()+e+f;try{t[g?"replaceState":"pushState"](h,"",m),s.value=h}catch(y){console.error(y),n[g?"replace":"assign"](m)}}function a(f,h){i(f,Oe({},t.state,Ca(s.value.back,f,s.value.forward,!0),h,{position:s.value.position}),!0),r.value=f}function u(f,h){let g=Oe({},s.value,t.state,{forward:f,scroll:us()});i(g.current,g,!0),i(f,Oe({},Ca(r.value,f,null),{position:g.position+1},h),!1),r.value=f}return{location:r,state:s,push:u,replace:a}}function Va(e){e=fa(e);let t=Fc(e),n=Uc(e,t.state,t.location,t.replace);function r(i,a=!0){a||n.pauseListeners(),history.go(i)}let s=Oe({locatLine truncated
|
||||
`)),e.options=Object.assign({},e.options,h.options),i.restore()})}a();let u=null;e.saveDefault=f=>{if(f&&f.preventDefault&&f.preventDefault(),e.saving)return;let h=i.checkpoint(),g={terms:e.terms.split(`
|
||||
`).map(k=>k.trim()).filter(k=>k.length>0),options:e.options};e.saving=!0,e.error=null,t.post("/api/user/default",g).then(()=>{i.accept(h),e.saving=!1,e.message=e.unsavedChanges?null:"Saved",u&&r.cancel(u),u=r(()=>{e.message=null},2500)},k=>{e.saving=!1;let y="ERRORS."+(k&&k.data&&k.data.error);n(y).then(E=>{e.error=E&&E!==y?E:"Unable to save your defaults. Please try again."},()=>{e.error="Unable to save your defaults. Please try again."})})},e.deleteAccount=()=>{confirm("Delete your account? All your anonymized repositories, gists, and pull requests will be removed, and your personal data will be erased. This cannot be undone.")&&(e.deletingAccount=!0,t.delete("/api/user").then(()=>{window.location.href="/"},()=>{e.deletingAccount=!1,e.deleteError="Unable to delete the account. Please try again."}))}},Ba=function(e,t,n){e.repoId=null,e.repoUrl=null,e.claim=()=>{t.post("/api/repo/claim",{repoId:e.repoId,repoUrl:e.repoUrl}).then(r=>{n.url("/dashboard")},r=>{e.error=r.data,e.claimForm.repoUrl.setValidity("not_found",!1),e.claimForm.repoId.setValidity("not_found",!1)})}},ja=function(e,t,n,r,s){e.user&&!e.user.status&&n.url("/dashboard"),e.watch("user.status",()=>{e.user&&!e.user.status&&n.url("/dashboard")}),e.features=[{key:"anonymize",num:"01",eyebrow:"Anonymize",title:"Double-anonymous,",accent:"your rules.",text:"Choose what reviewers may see: links, images, PDFs, notebooks, GitHub Pages. Add your own terms, with regex if you need it, and pick the expiration date.",cta:"Start an anonymization",href:"/anonymize",url:"anonymous.4open.science/anonymize",img:"/imgs/anonymize.png",alt:"The anonymize form: source repository, terms to redact, options and a live README preview"},{key:"review",num:"02",eyebrow:"Review",title:"Reviewers browse",accent:"the real thing.",text:"Highlighted source code, rendered PDFs, images, and notebooks, in a familiar file explorer. GitHub Pages is also supported.",cta:"Open the example",href:"https://anonymous.4open.science/r/840c8c57-3c32-451e-bf12-0e20be300389/",target:"_self",url:"anonymous.4open.science/r/840c8c57-\u2026",img:"/imgs/explorer.png",alt:"The repository explorer with a file tree and a rendered README"},{key:"manage",num:"03",eyebrow:"Manage",title:"One dashboard,",accent:"until the decision.",text:"Monitor views, edit configuration, remove or update your repository. Program chairs can group submissions under a conference with one shared expiry.",cta:"Open the dashboard",href:"/dashboard",needsUser:!0,url:"anonymous.4open.science/dashboard",img:"/imgs/dashboard.png",alt:"The dashboard listing anonymized repositories with status, views and expiry"}],e.feature=e.features[0].key,e.selectFeature=function(u){e.feature=u},e.featureHref=function(u){return u.needsUser&&!e.user?"/signin":u.href},e.featureTarget=function(u){return u.needsUser&&!e.user?"_self":u.target||void 0},e.featureKeydown=function(u,f){let h={ArrowDown:1,ArrowRight:1,ArrowUp:-1,ArrowLeft:-1,Home:"first",End:"last"}[u.key];if(h===void 0)return;u.preventDefault();let g=e.features.length,k=h==="first"?0:h==="last"?g-1:(f+h+g)%g;e.feature=e.features[k].key,s(()=>{let m=r.document.getElementById("feature-tab-"+e.feature);m&&m.focus()})},e.cards=[{key:"repositories",total:0,label:"repositories anonymized"},{key:"users",total:0,label:"researchers"},{key:"pageViews",total:0,label:"page views"},{key:"pullRequests",total:0,label:"pull requests"}];function i(){t.get("/api/stat/").then(u=>{e.stat=u.data,e.cards[0].total=u.data.nbRepositories,e.cards[1].total=u.data.nbUsers,e.cards[2].total=u.data.nbPageViews,e.cards[3].total=u.data.nbPullRequests})}i();function a(u){let f={series:u,bars:[],viewW:100,deltaToday:0,pctChange:0,pctAbs:0,isUp:!0};if(!u||u.length<2)return f;let h=new Array(u.length-1);for(let w=1;w<u.length;w++)h[w-1]=u[w]-u[w-1];let g=h.length,k=Math.max.apply(null,h),m=Math.min.apply(null,h),y=Math.min(0,m),E=k-y||1;f.viewW=g*2,f.bars=new Array(g);for(let w=0;w<g;w++){let R=(h[w]-y)/E,D=Math.max(1.5,R*34);f.bars[w]={x:(w*2+.25).toFixed(2),y:(36-D).toFixed(2),w:"1.5",h:D.toFixed(2)}}if(f.deltaToday=h[g-1],g>=2){let w=h[g-2];w&&(f.pctChange=(f.deltaToday-w)/w*100)}return f.pctAbs=Math.round(Math.abs(f.pctChange)),f.isUp=f.pctChange>=0,f}e.history={repositories:a([]),users:a([]),pageViews:a([]),pullRequests:a([])},t.get("/api/stat/history?days=60").then(u=>{let f=u.data||[];e.history={repositories:a(f.map(h=>h.nbRepositories||0)),users:a(f.map(h=>h.nbUsers||0)),pageViews:a(f.map(h=>h.nbPageViews||0)),pullRequests:a(f.map(h=>h.nbPullRequests||0))}})},Ga=function(e,t,n,r,s,i){let a=ct();e.on("routeLeave",function(){$('[data-toggle="tooltip"]').tooltip("dispose")}),e.watch("user.status",()=>{e.user==null&&n.url("/")}),e.user==null&&n.url("/"),a.timeout(()=>{$('[data-toggle="tooltip"]').tooltip()},250),e.quotaExpanded=!1,e.closeQuota=()=>{e.quotaExpanded=!1,st(()=>document.querySelector(".dashboard-quota > summary")?.focus())},Qt()(document,"click",_=>{e.quotaExpanded&&!_.target.closest?.(".dashboard-quota")&&
Line truncated
|
||||
`)),e.options=Object.assign({},e.options,j.options),e.options.expirationDate=j.options&&j.options.expirationDate?new Date(j.options.expirationDate):A(),!r.repoId&&!r.pullRequestId&&!r.gistId&&!e.anonymize?.terms?.dirty&&(e.terms=e.defaultTerms),S&&S()})}function U(S,I,j){e.anonymize&&e.anonymize[S]&&e.anonymize[S].setValidity(I,j)}function ae(S){try{let I=parseGithubUrl(S);if(I&&I.owner&&I.repo)return I.owner+"/"+I.repo}catch{}return null}e.reconnectingSource=!1,e.reconnectRepositoryId=void 0,e.reconnectSource=async()=>{if(confirm("Reconnect to the repository currently at this GitHub URL? Review its branch and commit before saving. Saving will replace the published snapshot.")){e.reconnectingSource=!0,e.reconnectRepositoryId=void 0,e.source.commit="",e.source.branch="",e.readme="",e.branches=[];try{await G()}catch{}}};function ie(){return e.reconnectingSource||!e.isUpdate||!e._originalRepositoryID?void 0:ae(e.sourceUrl)===e._originalFullName?e._originalRepositoryID:void 0}async function _(){e._preservingDraft=!0;try{await e.urlSelected(!0)}finally{e._preservingDraft=!1}}async function O(){let S;try{S=JSON.parse(sessionStorage.getItem("github-access-draft")||"null")}catch{return!1}if(!S||S.path!==s.path()||Date.now()-S.savedAt>=30*6e4)return R.restore()?(e.sourceUrl&&await _(),!0):!1;R.restore();for(let I of["sourceUrl","terms","repoId","pullRequestId","gistId","source","options","conference","githubConnection"])Object.prototype.hasOwnProperty.call(S.draft,I)&&(e[I]=S.draft[I]);return e.options.expirationDate&&(e.options.expirationDate=new Date(e.options.expirationDate)),await _(),sessionStorage.removeItem("github-access-draft"),!0}q(()=>{!r.repoId&&!r.pullRequestId&&!r.gistId&&a(O,0),r.repoId&&r.repoId!=""&&(e.isUpdate=!0,e.detectedType="repo",e.repoId=r.repoId,t.get("/api/repo/"+e.repoId).then(async S=>{e.githubConnection=S.data.connection||"oauth",e.sourceUrl="https://github.com/"+S.data.source.fullName,e._originalFullName=S.data.source.fullName,e.terms=S.data.options.terms.filter(I=>I).join(`
|
||||
`),e.source=S.data.source,e.role=S.data.role||"owner",e.coauthors=S.data.coauthors||[],e._originalBranch=S.data.source.branch,e.options=Object.assign({},e.options,S.data.options),e.conference=S.data.conference,e._originalConference=S.data.conference,e.repositoryID=S.data.source.repositoryID,e._originalRepositoryID=S.data.source.repositoryID,S.data.options.expirationDate&&(e.options.expirationDate=new Date(S.data.options.expirationDate)),E=w(parseGithubUrl(e.sourceUrl)),!await O()&&(await Promise.all([G(),J()]),ue())},()=>{s.url("/404")})),r.pullRequestId&&r.pullRequestId!=""&&(e.isUpdate=!0,e.detectedType="pr",e.pullRequestId=r.pullRequestId,t.get("/api/pr/"+e.pullRequestId).then(async S=>{if(e.githubConnection=S.data.connection||"oauth",e.sourceUrl="https://github.com/"+S.data.source.repositoryFullName+"/pull/"+S.data.source.pullRequestId,e.terms=S.data.options.terms.filter(I=>I).join(`
|
||||
`),e.source=S.data.source,e.options=Object.assign({},e.options,S.data.options),e.conference=S.data.conference,e._originalConference=S.data.conference,S.data.options.expirationDate&&(e.options.expirationDate=new Date(S.data.options.expirationDate)),E=w(parseGithubUrl(e.sourceUrl)),!await O())try{await pe()}catch{}},()=>{s.url("/404")})),r.gistId&&r.gistId!=""&&(e.isUpdate=!0,e.detectedType="gist",e.gistId=r.gistId,t.get("/api/gist/"+e.gistId).then(async S=>{e.sourceUrl="https://gist.github.com/"+S.data.source.gistId,e.terms=S.data.options.terms.filter(I=>I).join(`
|
||||
`),e.source=S.data.source,e.options=Object.assign({},e.options,S.data.options),e.conference=S.data.conference,e._originalConference=S.data.conference,S.data.options.expirationDate&&(e.options.expirationDate=new Date(S.data.options.expirationDate)),E=w(parseGithubUrl(e.sourceUrl)),!await O()&&await Ae()},()=>{s.url("/404")}))}),t.get("/github/connections").then(S=>{e.githubConnections=S.data,!r.repoId&&!r.pullRequestId&&!r.gistId&&e.githubConnection===void 0&&S.data.appEnabled&&S.data.appConnected&&(e.githubConnection="github-app",e.sourceUrl&&e.workspaceOpen&&_())}).catch(()=>{}),e.urlSelected=async(S=!1)=>{let I=++f,j;try{j=parseGithubUrl(e.sourceUrl.trim())}catch{e.sourceLoading=!1,oe.cancel(),_e.cancel(),Se.cancel(),e.previewStatus="idle",e.sourceUrlError=e.sourceUrl?"Please provide a valid GitHub URL.":"",U("sourceUrl","github",!1);return}let M=w(j),v=S||E===M;E=M,e.sourceUrlError="",U("sourceUrl","github",!0);let C=document.activeElement?.id==="sourceUrl-landing";e.workspaceOpen=!0,C&&a(()=>document.getElementById("sourceUrl")?.focus(),0),e.reconnectingSource=!1,e.reconnectRepositoryId=void 0,!v&&!e.isUpdate&&(e.repoId="",e.pullRequestId="",e.gistId=""),e.details=null,e.branches=[],v||(e.source={type:"GitHubStream",branch:"",commit:""}),oe.cancel(),_e.cancel(),Se.cancel(),le.clear(),Ee.clear(),e.previewStatus="idle",e.anonymize_readme="",e.readme="",e.html_readme="",e.detectedType=null,e.gistOAuthRequired=!1,e.gistAppRequired=!1,e._preservingDraft=v;try{j.gistId&&!j.repo?(e.detectedType="gist",e.source={gistId:j.gistId},await Ae()):j.pullRequestId?(e.detectedType="pr",e.source={repositoryFullName:j.owner+"/"+j.repo,pullRequestId:j.pullRequestId},await pe()):(e.detectedType="repo",await Promise.all([G(),J()]),ue())}catch{}finally{I===f&&(e._preservingDraft=!1)}$('[data-toggle="tooltip"]').tooltip()},$('[data-toggle="tooltip"]').tooltip(),e.watch("source.branch",async()=>{if(e.detectedType!=="repo")return;let S=e.branches.filter(j=>j.name==e.source.branch)[0];if(!S)return;!(!e.reconnectingSource&&e.isUpdate&&e._originalBranch===e.source.branch&&e.source.commit)&&!(e._preservingDraft&&e.source.commit)&&(e.source.commit=S.commit),e.readme=S.readme,await J(),ue()}),e.getBranches=async S=>{let I=parseGithubUrl(e.sourceUrl),j=m();try{let M=await t.get(`/api/repo/${I.owner}/${I.repo}/branches`,{params:{reconnect:e.reconnectingSource?"1":void 0,anonymizedRepoId:e.isUpdate&&r.repoId&&ae(e.sourceUrl)===e._originalFullName?r.repoId:void 0,connection:e.githubConnection,force:S===!0||e.reconnectingSource?"1":"0",repositoryID:ie()}});if(!k(j))return;e.branches=M.data,e.sourceUnreachable=!1,e.source.branch||(e.source.branch=e.details.defaultBranch);let v=e.branches.filter(C=>C.name==e.source.branch);v.length>0&&(!(!e.reconnectingSource&&!S&&e.isUpdate&&!e.options.update&&e._originalBranch===e.source.branch&&e.source.commit)&&!(e._preservingDraft&&e.source.commit)&&(e.source.commit=v[0].commit),e.readme=v[0].readme,await J(S))}catch(M){if(!k(j))return;e.branches=[],e.sourceUnreachable=M&&(M.status===404||M.data&&M.data.error==="repo_not_found");let v=M&&M.data&&M.data.error||(M&&M.status===404?"repo_not_found":"unknown_error");i("ERRORS."+v).then(C=>{e.toasts=e.toasts||[],e.addToast({title:"Error",date:new Date,body:C}),e.error=C},console.error),typeof U=="function"&&U("sourceUrl","missing",!1)}finally{y(j)}};async function G(){let S=parseGithubUrl(e.sourceUrl),I=m();try{z();let j=await t.get(`/api/repo/${S.owner}/${S.repo}/`,{params:{reconnect:e.reconnectingSource?"1":void 0,anonymizedRepoId:e.isUpdate&&r.repoId&&ae(e.sourceUrl)===e._originalFullName?r.repoId:void 0,connection:e.githubConnection,repositoryID:ie(),force:"1"}});if(!k(I))return;e.details=j.data,e.reconnectingSource&&(e.reconnectRepositoryId=e.details.externalId),e.details&&e.details.id&&(e.repositoryID=e.details.id),e.repoId||(e.repoId=e.details.repo+"-"+generateRandomId(4)),await e.getBranches(e.reconnectingSource)}catch(j){if(!k(I))return;throw j.data&&(i("ERRORS."+j.data.error).then(M=>{e.addToast({title:"Error",date:new Date,body:M}),e.error=M},console.error),te(j.data.error)),U("sourceUrl","missing",!1),j}finally{y(I)}}async function J(S){if(e.readme&&!S)return e.readme;let I=parseGithubUrl(e.sourceUrl),j=m();try{let M=await t.get(`/api/repo/${I.owner}/${I.repo}/readme`,{params:{reconnect:e.reconnectingSource?"1":void 0,anonymizedRepoId:e.isUpdate&&r.repoId&&ae(e.sourceUrl)===e._originalFullName?r.repoId:void 0,connection:e.githubConnection,force:S===!0?"1":"0",branch:e.source.branch,repositoryID:ie()}});k(j,!0)&&(e.readme=M.data)}catch{k(j,!0)&&(e.readme="")}finally{y(j)}}function ee(){let S={terms:e.terms?e.terms.split(`
|
||||
`):[],image:!!e.options.image,link:!!e.options.link,repoId:e.repoId};e.source&&e.source.branch&&(S.branchName=e.source.branch);try{let I=parseGithubUrl(e.sourceUrl);S.repoName=`${I.owner}/${I.repo}`}catch{}return S}function Q(S,I,j){let M=null,v=0,C=()=>{if(e.detectedType!==S)return;M&&a.cancel(M);let P=++v;e.previewStatus="loading",M=a(()=>{M=null;let F=I();if(!F){e.previewStatus="idle";return}t.post("/api/anonymize-preview",F).then(B=>{P===v&&(j(B.data,F),e.previewStatus="ready")}).catch(()=>{P===v&&(e.previewStatus="error")})},200)};return C.cancel=()=>{v++,M&&a.cancel(M),M=null},e.on("dispose",C.cancel),C}e.retryPreview=()=>{e.detectedType==="repo"&&oe(),e.detectedType==="pr"&&_e(),e.detectedType==="gist"&&Se()};let oe=Q("repo",()=>e.readme?{content:e.readme,options:ee()}:null,S=>{if(typeof S?.content!="string")throw Error("Invalid preview response");e.anonymize_readme=S.content;let I="";try{let M=parseGithubUrl(e.sourceUrl),v=e.source.branch||e.details&&e.details.defaultBranch||"main";I=`https://github.com/${M.owner}/${M.repo}/raw/${v}/`}catch{}let j=renderMD(e.anonymize_readme,I);e.html_readme=j,a(Prism.highlightAll,150)});function ue(){!e.anonymize||!e.anonymize.terms||(e.termsRegexWarning=!!e.terms&&!!e.terms.match(/[-[\]{}()*+?.,\\^$|#]/g),oe())}async function pe(){let S=parseGithubUrl(e.sourceUrl),I=m();try{z();let j=await t.get(`/api/pr/${S.owner}/${S.repo}/${S.pullRequestId}`,{params:{connection:e.githubConnection}});if(!k(I))return;e.details=j.data,e.pullRequestId||(e.pullRequestId=S.repo+"-PR"+S.pullRequestId+"-"+generateRandomId(4))}catch(j){if(!k(I))return;throw j.data&&(i("ERRORS."+j.data.error).then(M=>{e.addToast({title:"Error",date:new Date,body:M}),e.error=M},console.error),te(j.data.error)),U("sourceUrl","missing",!1),j}finally{y(I)}}let le=He(new Map);function Ie(){let S=new Set,I=e.details&&e.details.pullRequest;if(!I)return S;typeof I.title=="string"&&S.add(I.title),typeof I.body=="string"&&S.add(I.body),typeof I.diff=="string"&&S.add(I.diff);let j=I.comments||[];for(let M of j)typeof M.author=="string"&&S.add(M.author),typeof M.body=="string"&&S.add(M.body);return S}let _e=Q("pr",()=>{let S=Ie(),I=Array.from(S);return I.length===0?null:{contents:I,options:ee()}},(S,I)=>{if(!Array.isArray(S?.contents)||S.contents.length!==I.contents.length||S.contents.some(v=>typeof v!="string"))throw Error("Invalid preview response");let j=I.contents,M=new Map;for(let v=0;v<j.length&&v<S.contents.length;v++)M.set(j[v],S.contents[v]);le.clear(),M.forEach((v,C)=>le.set(C,v))});e.anonymizePrContent=function(S){return S&&(le.has(S)?le.get(S):"")};async function Ae(){let S=parseGithubUrl(e.sourceUrl),I=m();try{z(),e.gistOAuthRequired=!1,e.gistAppRequired=!1;let j=await t.get(`/api/gist/source/${S.gistId}`);if(!k(I))return;e.details=j.data,e.gistId||(e.gistId="gist-"+S.gistId.substring(0,6)+"-"+generateRandomId(4))}catch(j){if(!k(I))return;if(j.data?.error==="github_app_reconnect_required"){e.gistAppRequired=!0,U("sourceUrl","missing",!1);return}if(j.data?.error==="github_oauth_required"){e.gistOAuthRequired=!0,U("sourceUrl","missing",!1);return}throw j.data&&(i("ERRORS."+j.data.error).then(M=>{e.addToast({title:"Error",date:new Date,body:M}),e.error=M},console.error),te(j.data.error)),U("sourceUrl","missing",!1),j}finally{y(I)}}let Ee=He(new Map);function Pe(){let S=new Set,I=e.details&&e.details.gist;if(!I)return S;typeof I.description=="string"&&S.add(I.description),typeof I.ownerLogin=="string"&&S.add(I.ownerLogin);let j=I.files||[];for(let v of j)typeof v.filename=="string"&&S.add(v.filename),typeof v.content=="string"&&S.add(v.content);let M=I.comments||[];for(let v of M)typeof v.author=="string"&&S.add(v.author),typeof v.body=="string"&&S.add(v.body);return S}let Se=Q("gist",()=>{let S=Pe(),I=Array.from(S);return I.length===0?null:{contents:I,options:ee()}},(S,I)=>{if(!Array.isArray(S?.contents)||S.contents.length!==I.contents.length||S.contents.some(v=>typeof v!="string"))throw Error("Invalid preview response");let j=I.contents,M=new Map;for(let v=0;v<j.length&&v<S.contents.length;v++)M.set(j[v],S.contents[v]);Ee.clear(),M.forEach((v,C)=>Ee.set(C,v)),$e()});e.anonymizeGistContent=function(S){return S&&(Ee.has(S)?Ee.get(S):"")},e.previewGistFiles=[];function $e(){let S=e.details&&e.details.gist&&e.details.gist.files||[];e.previewGistFiles=S.map(I=>({filename:e.anonymizeGistContent(I.filename),content:e.anonymizeGistContent(I.content),language:I.language}))}e.watch("details.gist.files",$e,!0),e.watch("terms",$e);function Ue(){let S=e.conference;if(e.conference_data=null,!S)return;let I=e.isUpdate&&S===e._originalConference;t.get("/api/conferences/"+S).then(j=>{e.conference===S&&(e.conference_data=j.data,e.conference_data.startDate=new Date(e.conference_data.startDate),e.conference_data.endDate=new Date(e.conference_data.endDate),!I&&(e.options.expirationDate=new Date(e.conference_data.endDate),e.options.expirationMode="remove",e.options.update=e.conference_data.options.update,e.options.image=e.con
Line truncated
|
||||
`).filter(C=>C),connection:e.githubConnection,fullName:`${j.owner}/${j.repo}`,repository:e.sourceUrl,reconnectRepositoryId:e.reconnectRepositoryId,options:e.options,source:e.source,conference:e.conference};e.details&&(M.options.pageSource=e.details.pageSource),z();let v=e.isUpdate?"/api/repo/"+e.repoId:"/api/repo/";t.post(v,M,{headers:{"Content-Type":"application/json"}}).then(()=>{R.clear(),window.location.href="/status/"+e.repoId},C=>{C.data&&(i("ERRORS."+C.data.error).then(P=>{e.error=P},console.error),te(C.data.error))}).finally(()=>{I.disabled=!1})},e.anonymizeGist=S=>{if(e.sourceLoading){e.error="Wait for the source to finish loading before saving.";return}if(ne())return;if(e.anonymize?.invalid||e.sourceUrlError){e.error="Please correct the highlighted fields before saving.";return}let I=S.currentTarget||S.target.closest?.("button")||S.target;I.disabled=!0;let j=parseGithubUrl(e.sourceUrl),M={gistId:e.gistId,terms:e.terms.trim().split(`
|
||||
`).filter(C=>C),source:{gistId:j.gistId},options:e.options,conference:e.conference};z();let v=e.isUpdate?"/api/gist/"+e.gistId:"/api/gist/";t.post(v,M,{headers:{"Content-Type":"application/json"}}).then(()=>{R.clear(),window.location.href="/gist/"+e.gistId},C=>{C.data&&(i("ERRORS."+C.data.error).then(P=>{e.error=P},console.error),te(C.data.error))}).finally(()=>{I.disabled=!1})},e.anonymizePullRequest=S=>{if(e.sourceLoading){e.error="Wait for the source to finish loading before saving.";return}if(ne())return;if(e.anonymize?.invalid||e.sourceUrlError){e.error="Please correct the highlighted fields before saving.";return}let I=S.currentTarget||S.target.closest?.("button")||S.target;I.disabled=!0;let j=parseGithubUrl(e.sourceUrl),M={pullRequestId:e.pullRequestId,connection:e.githubConnection,terms:e.terms.trim().split(`
|
||||
`).filter(C=>C),source:{repositoryFullName:`${j.owner}/${j.repo}`,pullRequestId:j.pullRequestId},options:e.options,conference:e.conference};z();let v=e.isUpdate?"/api/pr/"+e.pullRequestId:"/api/pr/";t.post(v,M,{headers:{"Content-Type":"application/json"}}).then(()=>{R.clear(),window.location.href="/pr/"+e.pullRequestId},C=>{C.data&&(i("ERRORS."+C.data.error).then(P=>{e.error=P},console.error),te(C.data.error))}).finally(()=>{I.disabled=!1})},e.watch("conference",()=>{Ue()}),e.watch("terms",()=>{e.detectedType==="repo"&&ue(),e.detectedType==="pr"&&_e(),e.detectedType==="gist"&&Se()}),e.watch("options.image",()=>{e.detectedType==="repo"&&ue(),e.detectedType==="pr"&&_e(),e.detectedType==="gist"&&Se()}),e.watch("options.link",()=>{e.detectedType==="repo"&&ue(),e.detectedType==="pr"&&_e(),e.detectedType==="gist"&&Se()}),e.watch("details",()=>{e.detectedType==="pr"&&_e(),e.detectedType==="gist"&&Se()},!0)},sr=function(e,t,n,r,s,i){let a=ct(),u=Qt();e.on("dark-mode",(O,G)=>{e.aceOption&&(G?e.aceOption.theme="nord_dark":e.aceOption.theme="chrome")});let f=0,h=!1;e.on("dispose",()=>{h=!0,f++,g&&g.resolve()}),e.files=[],e.isMac=/Mac|iPhone|iPad|iPod/.test(navigator.platform||navigator.userAgent),e.fileSearchQuery="",e.fileSearchResults=null,e.fileSearchLoading=!1,u(document,"keydown",function(O){if((O.metaKey||O.ctrlKey)&&O.key==="k"){O.preventDefault();var G=document.querySelector(".tree-search-input");G&&(G.focus(),G.select())}});var g=null;e.onFileSearchChange=function(){g&&(g.resolve(),g=null);let O=e.fileSearchQuery;if(!O||O.length<2){e.fileSearchResults=null,e.fileSearchLoading=!1;return}e.fileSearchLoading=!0;let G=i.defer();g=G,t.get(`/api/repo/${e.repoId}/files/search?q=${encodeURIComponent(O)}`,{timeout:G.promise}).then(function(J){if(!(h||g!==G)){g=null,e.fileSearchLoading=!1;var ee={};e.files.forEach(function(z){ee[(z.path||"")+"/"+z.name]=!0});for(var Q=[],oe={},ue=0;ue<J.data.length;ue++){var pe=J.data[ue];if(pe.path)for(var le=pe.path.split("/"),Ie="",_e=0;_e<le.length;_e++){var Ae=Ie;Ie=Ie?Ie+"/"+le[_e]:le[_e];var Ee=Ae+"/"+le[_e];!ee[Ee]&&!oe[Ee]&&(oe[Ee]=!0,Q.push({name:le[_e],path:Ae}))}}Q.sort(function(z,ne){return(z.path||"").split("/").length-(ne.path||"").split("/").length}),Q.length>0&&e.files.push.apply(e.files,Q);for(var Pe=[],Se=0;Se<J.data.length;Se++){var $e=J.data[Se],Ue=($e.path||"")+"/"+$e.name;!ee[Ue]&&$e.size!=null&&(Pe.push($e),ee[Ue]=!0)}Pe.length>0&&e.files.push.apply(e.files,Pe),e.fileSearchResults=J.data}},function(){!h&&g===G&&(g=null,e.fileSearchLoading=!1,e.fileSearchResults=[])})};let k={yml:"yaml",txt:"text",py:"python",js:"javascript",ts:"typescript"},m=["license","txt"],y=["png","jpg","jpeg","gif","svg","ico","bmp","tiff","tif","webp","avif","heif","heic"],E=["wav","mp3","ogg","wma","flac","aac","m4a"],w=["mp4","avi","webm","mov","mpg","mpeg","mkv","flv","wmv","3gp","3g2","m4v","f4v","f4p","f4a","f4b"];e.on("routeUpdate",function(O,G){if(e.repoId!=r.repoId)return _();if((r.path||"")!=e.filePath){e.filePath=r.path||"",e.paths=e.filePath.split("/").filter(J=>J&&J.trim().length>0),ie();for(let J=0;J<e.paths.length-1;J++){let ee=J>0?e.paths.slice(0,J).join("/"):"";e.files.some(oe=>oe.path===ee)||e.getFiles(ee)}}});function R(){if(e.paths[0]!="")return;let O=["readme.md","readme.txt","readme.org","readme.1st","readme"],G={};for(let ee of e.files)ee.name.toLowerCase().indexOf("readme")>-1&&(G[ee.name.toLowerCase()]=ee.name);let J=null;for(let ee of O)if(G[ee]){J=ee;break}if(!J&&Object.keys(G).length>0&&(J=Object.keys(G)[0]),J){let ee=n.url();ee[ee.length-1]!="/"&&(ee+="/"),n.url(ee+encodePathForUrl(G[J]))}}e.fileCounts=null,e.getFiles=function(O){let G=e.repoId;return t.get(`/api/repo/${e.repoId}/files/?path=${encodeURIComponent(O)}&v=${e.options.lastUpdateDate}`).then(function(J){if(h||G!==e.repoId)return[];let ee=O||"";return e.files=e.files.filter(Q=>Q.path!==ee),e.files.push(...J.data),J.data},function(J){if(h||G!==e.repoId)return[];e.type="error",e.content=J&&J.data&&J.data.error||"unknown_error",e.files=[]})};function D(){let O=e.repoId;t.get(`/api/repo/${e.repoId}/files/counts`).then(function(G){h||O!==e.repoId||(e.fileCounts=G.data)},function(){e.fileCounts={}})}function A(){return e.files.filter(O=>O.name==e.paths[e.paths.length-1]&&O.path==e.paths.slice(0,e.paths.length-1).join("/"))[0]}var V=null;e.on("dispose",function(){V&&a.interval.cancel(V)});function H(O){if(h)return;let G=e.repoId;t.get(`/api/repo/${e.repoId}/options`).then(J=>{if(!(h||G!==e.repoId)){if(e.options=J.data,e.options.url){window.location=e.options.url;return}O&&O(J.data)}},J=>{if(!(h||G!==e.repoId)){var ee=J.data||{};if(ee.error==="rate_limited"&&ee.resetAt){let oe=function(){var ue=Math.max(0,Math.ceil((e.rateLimitResetAt-Date.now())/1e3));if(ue<=0)e.rateLimitCountdown="",e.rateLimitResetAt=0,V&&(a.interval.cancel(V),V=null),H(O);else{var pe=Math.floor(ue/60),le=ue%60;e.rateLimitCountdown=pe>0?pe+"m "+le+"s":le+"s"}};var Q=oe;e.type="rate_limited",e.rateLimitResetAt=ee.resetAt,V&&a.interval.canc
Line truncated
|
||||
`+w.join(`
|
||||
`)],{type:"text/csv"}),D=document.createElement("a");D.href=URL.createObjectURL(R),D.download=r.username+"-repositories.csv",D.click()},e.showStatusMessage=m=>{let y=m.statusMessage||"(no message)";window.prompt(`Status message for ${m.repoId} (${m.status}):`,y)},e.fetchGithubInfo=m=>{let y=window.open("","_blank");y&&y.document.write("<pre>Loading GitHub info for "+m.repoId+"...</pre>"),t.get("/api/admin/repos/"+m.repoId+"/github").then(E=>{y&&(y.document.open(),y.document.write('<pre style="font:13px monospace;padding:16px;white-space:pre-wrap">'+JSON.stringify(E.data,null,2).replace(/[<>]/g,w=>w==="<"?"<":">")+"</pre>"),y.document.close())},E=>{let w=E&&E.data?JSON.stringify(E.data,null,2):String(E);y&&(y.document.body.innerHTML='<pre style="color:#B42318;padding:16px">'+w+"</pre>")})};function f(m){t.get("/api/admin/users/"+m+"/repos",{}).then(y=>{e.repositories=y.data},y=>{console.error(y)})}function h(m){t.get("/api/admin/users/"+m,{}).then(y=>{e.userInfo=y.data},y=>{console.error(y)})}h(r.username),f(r.username),e.banUser=()=>{confirm(`Ban user ${r.username}?`)&&t.post(`/api/admin/users/${r.username}/ban`).then(()=>h(r.username),m=>console.error(m))},e.activateUser=()=>{t.post(`/api/admin/users/${r.username}/activate`).then(()=>h(r.username),m=>console.error(m))},e.promoteUser=()=>{confirm(`Promote ${r.username} to admin?`)&&t.post(`/api/admin/users/${r.username}/promote`).then(()=>h(r.username),m=>console.error(m))},e.demoteUser=()=>{confirm(`Remove admin privileges from ${r.username}?`)&&t.post(`/api/admin/users/${r.username}/demote`).then(()=>h(r.username),m=>console.error(m))},e.tokens=[],e.tokenForm={name:"",plaintext:null};function g(){t.get("/api/admin/tokens").then(m=>{e.tokens=m.data||[]},m=>{m.status!==401&&m.status!==403&&console.error(m)})}g(),e.createToken=()=>{e.tokenForm.name&&t.post("/api/admin/tokens",{name:e.tokenForm.name}).then(m=>{e.tokenForm.plaintext=m.data.token,e.tokenForm.name="",g()},m=>console.error(m))},e.revokeToken=m=>{confirm(`Revoke token "${m.name}"?`)&&t.delete("/api/admin/tokens/"+m.id).then(()=>g(),y=>console.error(y))},e.removeCache=m=>{confirm("Remove cached files for "+m.repoId+"?")&&t.delete("/api/admin/repos/"+m.repoId).then(()=>f(r.username),y=>console.error(y))},e.removeRepository=m=>{confirm("Remove repository "+m.repoId+"?")&&t.delete("/api/repo/"+m.repoId+"/").then(()=>f(r.username),y=>console.error(y))},e.updateRepository=m=>{let y=He({title:`Refreshing ${m.repoId}...`,date:new Date,body:`The repository ${m.repoId} is going to be refreshed.`});e.toasts.push(y),t.post(`/api/repo/${m.repoId}/refresh`).then(E=>{E.data.status=="ready"?y.title=`${m.repoId} is refreshed.`:y.title=`Refreshing of ${m.repoId}.`},E=>{y.title=`Error during the refresh of ${m.repoId}.`,y.body=E.body})},e.getGitHubRepositories=m=>{t.get(`/api/user/${e.userInfo.username}/all_repositories`,{params:{force:"1"}}).then(y=>{e.userInfo.repositories=y.data})};let k=null;e.watch("query",()=>{s.timeout.cancel(k),k=s.timeout(()=>{f(r.username)},500)},!0)},ol=function(e,t,n){let r=ct(),s=Qt();e.Math=Math,e.watch("user.status",()=>{e.user==null&&n.url("/")}),e.user==null&&n.url("/"),e.conferences=[],e.total=-1,e.totalPage=0,e.statusCounts=[];let i=E=>{if(E.key==="/"&&!["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName)){E.preventDefault();let w=document.querySelector('.admin-filter-toolbar input[type="search"]');w&&w.focus()}};s(document,"keydown",i),e.on("dispose",()=>document.removeEventListener("keydown",i)),e.clearFilter=E=>{E==="dateRange"?(e.query.dateFrom="",e.query.dateTo=""):e.query[E]="",e.query.page=1},e.chips=[];let a=()=>{let E=[];(e.query.dateFrom||e.query.dateTo)&&E.push({key:"dateRange",label:"Date",value:(e.query.dateFrom||"\u2026")+" \u2013 "+(e.query.dateTo||"\u2026")}),e.chips=E};e.statusCountFor=E=>{let w=(e.statusCounts||[]).find(R=>R._id===E);return w?w.count:0},e.toggleSortDirection=()=>{e.query.direction=e.query.direction==="asc"?"desc":"asc"},e.sortBy=E=>{e.query.sort===E?e.query.direction=e.query.direction==="asc"?"desc":"asc":(e.query.sort=E,e.query.direction="desc"),e.query.page=1},e.sortIcon=E=>e.query.sort===E?e.query.direction==="asc"?"fa-arrow-up":"fa-arrow-down":"";let u="admin.conferences.filterPrefs",f={page:1,limit:25,sort:"name",direction:"asc",search:"",dateFrom:"",dateTo:"",ready:!1,expired:!1,removed:!1,error:!0,preparing:!0},h=loadFilterPrefs(u)||{};e.query=Object.assign({},f,h,{page:1,search:""});let g=n.search();g.search&&(e.query.search=g.search);let k="admin.conferences.presets";e.presets=JSON.parse(localStorage.getItem(k)||"[]"),e.savePreset=()=>{let E=window.prompt("Preset name:");if(!E)return;let w=Object.assign({},e.query);delete w.page,e.presets=(e.presets||[]).filter(R=>R.name!==E),e.presets.push({name:E,query:w}),localStorage.setItem(k,JSON.stringify(e.presets))},e.applyPreset=E=>{Object.assign(e.query,E.query,{page:1})},e.deletePreset=E=>{e.presets=(e.presets||[]).filter(w=>w.name!==E.name),localStorage.setItem(k,JS
Line truncated
|
||||
`).map((ue,pe)=>pe===0?ue:oe+ue).join(`
|
||||
`)}else typeof _=="number"||typeof _=="boolean"?Q=String(_):Q=JSON.stringify(_);ae.push(`${J}${Q}${ee}`)}),ae.push("}"),ae.join(`
|
||||
`)}function h(D){let A=[],V="",H=/(\w+):(>=|<=|!=|>|<|=)?([^\s]+)/g,q=0,U;for(;U=H.exec(D);)V+=D.slice(q,U.index),q=H.lastIndex,A.push({key:U[1],op:U[2]||"=",val:U[3]});return V+=D.slice(q),{filters:A,free:V.trim().toLowerCase()}}function g(D,A){for(let V of A.filters){let H=(U,ae,ie)=>{let _=parseFloat(U),O=parseFloat(ae);return ie==="="?String(U)===String(ae):ie==="!="?String(U)!==String(ae):ie===">="?_>=O:ie==="<="?_<=O:ie===">"?_>O:ie==="<"?_<O:!0},q;if(V.key==="code")q=D.displayMessage;else if(V.key==="module")q=D.module;else if(V.key==="status")q=D._status;else if(V.key==="url")q=D._url;else if(V.key==="repo")q=D._repoId;else if(V.key==="level")q=D.level;else continue;if(q==null||!H(q,V.val,V.op))return!1}return!(A.free&&((D.displayMessage||"")+" "+(D.module||"")+" "+(D._url||"")+" "+JSON.stringify(D.raw||[])).toLowerCase().indexOf(A.free)===-1)}function k(){let D=h(e.query.search||"");e.parsedFilterCount=D.filters.length;let A=e.query.bucket,V=e.entries.filter(q=>A&&q._bucket!==A?!1:g(q,D)),H=e.query.group;if(H){let q=ie=>H==="module"?ie.module:ie.displayMessage||ie.message||"_",U=new Map;for(let ie of V){let _=q(ie);if(U.has(_)){let O=U.get(_);O.count++,O._related.push(ie),new Date(ie.ts)>new Date(O.ts)&&(O.ts=ie.ts,O._url=ie._url,O._status=ie._status),new Date(ie.ts)<new Date(O._firstSeen)&&(O._firstSeen=ie.ts)}else{let O=Object.assign({},ie);O._key=`${H}:${_}`,O._related=[ie],O._firstSeen=ie.ts,O._lastHourCount=0,O.count=1,U.set(_,O)}}let ae=Date.now()-3600*1e3;for(let ie of U.values())ie._lastHourCount=ie._related.filter(_=>new Date(_.ts).getTime()>=ae).length;V=Array.from(U.values())}else V=V.map((q,U)=>(q._key="row:"+U+":"+q.ts,q._related=[q],q._firstSeen=q.ts,q._lastHourCount=0,q.count=1,q));e.query.sort==="count"?V.sort((q,U)=>U.count-q.count||new Date(U.ts)-new Date(q.ts)):V.sort((q,U)=>new Date(U.ts)-new Date(q.ts)),e.visible=V}function m(D){let A=D?e.entries.length:0,V=D?e.pageSize:Math.max(e.pageSize,e.entries.length||e.pageSize);t.get("/api/admin/errors",{params:{offset:A,limit:V}}).then(H=>{let q=(H.data.entries||[]).map(u);e.entries=D?e.entries.concat(q):q,e.available=!!H.data.available,e.cap=H.data.max||e.cap,e.total=H.data.total||e.entries.length,k()},H=>console.error(H))}e.loadMore=()=>m(!0),e.canLoadMore=()=>e.entries.length<e.total;function y(){t.get("/api/admin/errors/stats").then(D=>{let A=D.data||{},V=A.prev24h?Math.round((A.last24h-A.prev24h)/A.prev24h*100):0;e.stats={last24h:A.last24h||0,prev24h:A.prev24h||0,delta:V,severity:A.severity||{error:0,warn:0,info:0},unique:A.unique||{error:0,warn:0,info:0},buckets:A.buckets||[],dropped:A.dropped||0}},D=>console.error(D))}function E(){m(),y()}e.barPx=(D,A)=>{let V=e.stats.buckets||[],H=0;for(let ie of V)H=Math.max(H,(ie.error||0)+(ie.warn||0)+(ie.info||0));if(!H)return 0;let q=(D.error||0)+(D.warn||0)+(D.info||0);if(!q)return 0;let U=Math.round(q/H*60),ae=D[A]||0;return Math.round(ae/q*U)},e.bucketTitle=D=>`${new Date(D.hour).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})} \xB7 ${D.error||0} err \xB7 ${D.warn||0} warn \xB7 ${D.info||0} info`,e.toggle=D=>{e.expanded[D._key]=!e.expanded[D._key]},e.setBucket=D=>{e.query.bucket=D},e.refreshNow=E,e.clearAll=()=>{confirm("Clear all captured errors?")&&t.delete("/api/admin/errors").then(E,D=>console.error(D))},e.exportCsv=()=>{let D=["ts","level","module","displayMessage","_status","_url","_repoId"],A=[D.join(",")];for(let U of e.visible)A.push(D.map(ae=>{let ie=U[ae]==null?"":String(U[ae]);return/[",\n]/.test(ie)?`"${ie.replace(/"/g,'""')}"`:ie}).join(","));let V=new Blob([A.join(`
|
||||
`)],{type:"text/csv;charset=utf-8"}),H=URL.createObjectURL(V),q=document.createElement("a");q.href=H,q.download=`errors-${new Date().toISOString().slice(0,19)}.csv`,document.body.appendChild(q),q.click(),document.body.removeChild(q),URL.revokeObjectURL(H)};function w(D){e.copyHint=`${D} copied`,s.timeout(()=>{e.copyHint=""},1500)}e.copyJson=D=>{navigator.clipboard.writeText(D._detailJson).then(()=>w("JSON"))},e.copyCurl=D=>{if(!D._url)return;let V=`curl -X ${D._method||"GET"} '${window.location.origin}${D._url}'`;navigator.clipboard.writeText(V).then(()=>w("curl"))},E();let R=r(()=>{e.query.autoRefresh&&E()},15e3);e.on("dispose",()=>r.cancel(R)),e.watch("query.search",k),e.watch("query.bucket",k),e.watch("query.sort",k),e.watch("query.group",k)},rl=function(e,t,n,r){if(e.Math=Math,e.watch("user.status",()=>{e.user==null&&n.url("/")}),e.user==null){n.url("/");return}e.data=null,e.loading=!0,e.error=null,e.performance=null,e.performanceMinutes=15,e.performanceTab="routes",e.performanceError=null,e.latencyBound=function(y,E){return y.count?y[E]==null?"> 60 s":"\u2264 "+y[E]+" ms":"No samples"},e.firstByteBound=function(y){return e.latencyBound({count:y.firstByteCount,p95UpperMs:y.firstByteP95UpperMs},"p95UpperMs")},e.sampleAge=function(y){return Math.max(0,Math.round((Date.now()-y.sampledAt)/1e3))},e.peakRss=function(y){return(e.performance.runtimeSeries||[]).filter(function(E){return E.instance===y.instance}).reduce(function(E,w){return Math.max(E,w.rss)},y.memory.rss)},e.performanceWarning=function(y){return e.sampleAge(y)>45||y.droppedBatches||y.droppedMetrics||y.sockets?.closeWait>0||y.eventLoop.p95Ms>100||y.memoryLimitBytes&&y.memory.rss>y.memoryLimitBytes*.8};var s=!1;e.loadPerformance=function(){var y=e.performanceMinutes;e.performance?.windowMinutes&&e.performance.windowMinutes!==y&&(e.performance=null),!s&&(s=!0,t.get("/api/admin/performance",{params:{minutes:y}}).then(function(E){y===e.performanceMinutes&&(e.performance=E.data,e.performanceError=E.data.available?null:"Performance monitoring is unavailable. Request history may be incomplete.")},function(){y===e.performanceMinutes&&(e.performanceError="Could not refresh performance monitoring. Displayed samples may be stale.")}).finally(function(){s=!1,y!==e.performanceMinutes&&e.loadPerformance()}))};function i(y){if(y==null)return"\u2014";for(var E=["B","KB","MB","GB","TB"],w=0,R=y;R>=1024&&w<E.length-1;)R/=1024,w++;return R.toFixed(w>0?1:0)+" "+E[w]}e.humanBytes=i;function a(y){if(!y)return"\u2014";var E=Math.floor(y/86400),w=Math.floor(y%86400/3600),R=Math.floor(y%3600/60);return E>0?E+"d "+(w<10?"0":"")+w+"h":w>0?w+"h "+(R<10?"0":"")+R+"m":R+"m"}e.humanDuration=a;function u(y){return y==null?"\u2014":y>=1e6?(y/1e6).toFixed(1)+"M":y>=1e3?(y/1e3).toFixed(1)+"K":String(y)}e.humanNum=u,e.queueTotal=function(y){return y?(y.waiting||0)+(y.active||0)+(y.delayed||0)+(y.failed||0):0},e.statusCount=function(y){if(!e.data||!e.data.repos)return 0;for(var E=e.data.repos.statusBreakdown||[],w=0;w<E.length;w++)if(E[w]._id===y)return E[w].count;return 0},e.barPct=function(y){var E=e.data&&e.data.repos?e.data.repos.total:0;if(!E)return 0;var w=[y];y==="expired"&&w.push("expiring"),y==="removed"&&w.push("removing"),y==="preparing"&&w.push("download");var R=0;return w.forEach(function(D){R+=e.statusCount(D)}),Math.max(.4,R/E*100)},e.errPct=function(y){if(!e.data||!e.data.errors)return 0;var E=Math.max(e.data.errors.severity.error,e.data.errors.severity.warn,e.data.errors.severity.info,1);return e.data.errors.severity[y]/E*100};function f(y){var E=y||[];return E.map(function(w,R){var D=E[R-1]||{},A=Object.assign({},w);return A.dailyRepositories=R?Math.max(0,(w.nbRepositories||0)-(D.nbRepositories||0)):0,A.dailyUsers=R?Math.max(0,(w.nbUsers||0)-(D.nbUsers||0)):0,A.dailyPageViews=R?Math.max(0,(w.nbPageViews||0)-(D.nbPageViews||0)):0,A})}function h(y){var E=y&&y.length?y[y.length-1]:{};return{repositories:E.dailyRepositories||0,users:E.dailyUsers||0,pageViews:E.dailyPageViews||0}}var g={};e.historyBarH=function(y,E){return!y||!g[E]?0:Math.max(1,Math.round(y[E]/g[E]*140))},e.historyLabel=function(y){if(!y||!y.date)return"";var E=new Date(y.date);return E.getUTCMonth()+1+"/"+E.getUTCDate()};function k(){e.loadPerformance(),t.get("/api/admin/overview").then(function(y){y.data.history=f(y.data.history),y.data.daily={today:h(y.data.history)},e.data=y.data,e.loading=!1,e.error=null,g={},(y.data.history||[]).forEach(function(E){["dailyPageViews","dailyRepositories","dailyUsers","nbUsers"].forEach(function(w){(!g[w]||E[w]>g[w])&&(g[w]=E[w])})})},function(y){e.loading=!1,e.error=y.data&&y.data.error||"Failed to load overview"})}k();var m=r(k,3e4);e.on("dispose",function(){r.cancel(m)})};var il=[{path:"/connections",template:"partials/connections.htm",title:"GitHub connections \u2013 Anonymous GitHub",preserveExplorer:!1,setup:(e,t)=>Xa(e,t.http)},{path:"/signin",template:"partials/signin.htm",title:"Sign in \u2013 Anonymous GitHub",preserveExplorer:!1,setup:(e,t)=>Za(e,t.http)}
Line truncated
|
||||
anonymous_github`)])])])])]),p(" Limitations "),o("h4",{class:"faq-cat"},[o("svg",{class:"ui-icon mr-2","aria-hidden":"true"},[o("use",{href:"#ui-icon-warning"})]),b("Limitations ")]),o("div",{class:"panel-group",id:"faq-limitations",role:"tablist","aria-multiselectable":"true"},[o("div",{class:"panel panel-default mb-3"},[o("div",{class:"panel-heading p-3",role:"tab",id:"headingLimitation"},[o("h3",{class:"panel-title"},[o("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-limitations",href:"#limitation","aria-expanded":"false","aria-controls":"limitation"}," What are the limitations of Anonymous GitHub? ")])]),o("div",{id:"limitation",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingLimitation"},[o("div",{class:"panel-body p-3"},[o("ul",null,[o("li",null," Anonymous GitHub only anonymizes text-based files. Binary files (compiled executables, archives, etc.) are served as-is without anonymization. "),o("li",null," Files larger than 8 MB are not supported. "),o("li",null," Static site generators (such as Jekyll) used with GitHub Pages are not fully supported, although Markdown files are converted to HTML. "),o("li",null," The anonymization of terms within source code may change the behavior of the program (e.g., if a replaced term appears in a string literal or identifier). ")])])])])]),p(" Privacy & Security "),o("h4",{class:"faq-cat"},[o("svg",{class:"ui-icon mr-2","aria-hidden":"true"},[o("use",{href:"#ui-icon-shield"})]),b("Privacy & Security ")]),o("div",{class:"panel-group",id:"faq-privacy",role:"tablist","aria-multiselectable":"true"},[o("div",{class:"panel panel-default mb-3"},[o("div",{class:"panel-heading p-3",role:"tab",id:"headingData"},[o("h3",{class:"panel-title"},[o("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-privacy",href:"#data","aria-expanded":"false","aria-controls":"data"}," How is my data handled? ")])]),o("div",{id:"data",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingData"},[o("div",{class:"panel-body p-3"},[o("p",null," Data stored on Anonymous GitHub is never shared or used for any purpose beyond providing the anonymization service. When a repository is removed or expires, only its configuration is retained \u2014 this makes it easy to restore the repository if needed and ensures that no future repository reuses the same ID. ")])])]),o("div",{class:"panel panel-default mb-3"},[o("div",{class:"panel-heading p-3",role:"tab",id:"headingPermissions"},[o("h3",{class:"panel-title"},[o("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-privacy",href:"#permissions","aria-expanded":"false","aria-controls":"permissions"}," Why does GitHub say Anonymous GitHub asks for write access? ")])]),o("div",{id:"permissions",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingPermissions"},[o("div",{class:"panel-body p-3"},[o("p",null,[b("The GitHub App has read-only access to the repositories you choose. You can change repository access on your "),o("a",{href:"/connections"},"GitHub connections"),b(" page.")]),o("p",null,"Legacy GitHub OAuth includes write access to private repositories because GitHub's OAuth scopes do not offer a read-only option for them. Existing OAuth anonymizations and gists can still use this connection."),o("p",null,"Anonymous GitHub reads repository content. It never pushes, modifies, or deletes files on GitHub.")])])]),o("div",{class:"panel panel-default mb-3"},[o("div",{class:"panel-heading p-3",role:"tab",id:"headingViewer"},[o("h3",{class:"panel-title"},[o("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-privacy",href:"#viewer","aria-expanded":"false","aria-controls":"viewer"}," Can repository owners see who viewed their anonymized repository? ")])]),o("div",{id:"viewer",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingViewer"},[o("div",{class:"panel-body p-3"},[o("p",null," No. Only the total number of views is tracked as an incremental counter. It is not possible for the repository owner or for Anonymous GitHub to identify individual viewers. Reviewer anonymity is fully preserved. ")])])])]),p(" Self-Hosting "),o("h4",{class:"faq-cat"},[o("svg",{class:"ui-icon mr-2","aria-hidden":"true"},[o("use",{href:"#project-icon-storage"})]),b("Self-Hosting ")]),o("div",{class:"panel-group",id:"faq-hosting",role:"tablist","aria-multiselectable":"true"},[o("div",{class:"panel panel-default mb-3"},[o("div",{class:"panel-heading p-3",role:"tab",id:"headingDeploy"},[o("h3",{class:"panel-title"},[o("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-hosting",href:"#deploy","aria-expanded":"false","aria-controls":"deploy"}," Can I deploy my own instance? ")])]),o("div",{id:"deploy",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingDeploy"},[o("div",{class:"panel-body p-3"},[o("p",null,[b(" Yes. Anonymous Git
Line truncated
|
||||
`+JSON.stringify(t):""}`},[je.NAVIGATION_GUARD_REDIRECT]({from:e,to:t}){return`Redirected from "${e.fullPath}" to "${Fc(t)}" via a navigation guard.`},[je.NAVIGATION_ABORTED]({from:e,to:t}){return`Navigation aborted from "${e.fullPath}" to "${t.fullPath}" via a navigation guard.`},[je.NAVIGATION_CANCELLED]({from:e,to:t}){return`Navigation cancelled from "${e.fullPath}" to "${t.fullPath}" with a new navigation.`},[je.NAVIGATION_DUPLICATED]({from:e,to:t}){return`Avoided redundant navigation to current location: "${e.fullPath}".`}};function pn(e,t){return Oe(new Error,{type:e,[ka]:!0},t)}function Lt(e,t){return e instanceof Error&&ka in e&&(t==null||!!(e.type&t))}var Uc=["params","query","hash"];function Fc(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;let t={};for(let s of Uc)s in e&&(t[s]=e[s]);return JSON.stringify(t,null,2)}function Ea(e){let t={};if(e===""||e==="?")return t;let s=(e[0]==="?"?e.slice(1):e).split("&");for(let i=0;i<s.length;++i){let o=s[i].replace(la," "),r=o.indexOf("="),a=Tn(r<0?o:o.slice(0,r)),c=r<0?null:Tn(o.slice(r+1));if(a in t){let f=t[a];kt(f)||(f=t[a]=[f]),f.push(c)}else t[a]=c}return t}function ni(e){let t="";for(let s in e){let i=e[s];if(s=Ac(s),i==null){i!==void 0&&(t+=(t.length?"&":"")+s);continue}(kt(i)?i.map(o=>o&&Ys(o)):[i&&Ys(i)]).forEach(o=>{o!==void 0&&(t+=(t.length?"&":"")+s,o!=null&&(t+="="+o))})}return t}function Ca(e){let t={};for(let s in e){let i=e[s];i!==void 0&&(t[s]=kt(i)?i.map(o=>o==null?null:""+o):i==null?i:""+i)}return t}var _a=Symbol(""),oi=Symbol(""),ts=Symbol(""),ns=Symbol(""),os=Symbol("");function On(){let e=[];function t(i){return e.push(i),()=>{let o=e.indexOf(i);o>-1&&e.splice(o,1)}}function s(){e=[]}return{add:t,list:()=>e.slice(),reset:s}}function Yt(e,t,s,i,o,r=a=>a()){let a=i&&(i.enterCallbacks[o]=i.enterCallbacks[o]||[]);return()=>new Promise((c,f)=>{let v=m=>{m===!1?f(pn(je.NAVIGATION_ABORTED,{from:s,to:t})):m instanceof Error?f(m):Lc(m)?f(pn(je.NAVIGATION_GUARD_REDIRECT,{from:t,to:m})):(a&&i.enterCallbacks[o]===a&&typeof m=="function"&&a.push(m),c())},g=r(()=>e.call(i&&i.instances[o],t,s,v)),k=Promise.resolve(g);e.length<3&&(k=k.then(v)),k.catch(m=>f(m))})}function ss(e,t,s,i,o=r=>r()){let r=[];for(let a of e)for(let c in a.components){let f=a.components[c];if(!(t!=="beforeRouteEnter"&&!a.instances[c]))if(ra(f)){let v=(f.__vccOpts||f)[t];v&&r.push(Yt(v,s,i,a,c,o))}else{let v=f();r.push(()=>v.then(g=>{if(!g)throw new Error(`Couldn't resolve component "${c}" at "${a.path}"`);let k=wc(g)?g.default:g;a.mods[c]=g,a.components[c]=k;let m=(k.__vccOpts||k)[t];return m&&Yt(m,s,i,a,c,o)()}))}}return r}function Sa(e,t){let s=[],i=[],o=[],r=Math.max(t.matched.length,e.matched.length);for(let a=0;a<r;a++){let c=t.matched[a];c&&(e.matched.find(v=>cn(v,c))?i.push(c):s.push(c));let f=e.matched[a];f&&(t.matched.find(v=>cn(v,f))||o.push(f))}return[s,i,o]}var Bc=()=>location.protocol+"//"+location.host;function Ma(e,t){let{pathname:s,search:i,hash:o}=t,r=e.indexOf("#");if(r>-1){let a=o.includes(e.slice(r))?e.slice(r).length:1,c=o.slice(a);return c[0]!=="/"&&(c="/"+c),Zs(c,"")}return Zs(s,e)+i+o}function zc(e,t,s,i){let o=[],r=[],a=null,c=({state:m})=>{let b=Ma(e,location),C=s.value,E=t.value,T=0;if(m){if(s.value=b,t.value=m,a&&a===C){a=null;return}T=E?m.position-E.position:0}else i(b);o.forEach(D=>{D(s.value,C,{delta:T,type:xo.pop,direction:T?T>0?es.forward:es.back:es.unknown})})};function f(){a=s.value}function v(m){o.push(m);let b=()=>{let C=o.indexOf(m);C>-1&&o.splice(C,1)};return r.push(b),b}function g(){if(document.visibilityState==="hidden"){let{history:m}=window;if(!m.state)return;m.replaceState(Oe({},m.state,{scroll:po()}),"")}}function k(){for(let m of r)m();r=[],window.removeEventListener("popstate",c),window.removeEventListener("pagehide",g),document.removeEventListener("visibilitychange",g)}return window.addEventListener("popstate",c),window.addEventListener("pagehide",g),document.addEventListener("visibilitychange",g),{pauseListeners:f,listen:v,destroy:k}}function Na(e,t,s,i=!1,o=!1){return{back:e,current:t,forward:s,replaced:i,position:window.history.length,scroll:o?po():null}}function Hc(e){let{history:t,location:s}=window,i={value:Ma(e,s)},o={value:t.state};o.value||r(i.value,{back:null,current:i.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function r(f,v,g){let k=e.indexOf("#"),m=k>-1?(s.host&&document.querySelector("base")?e:e.slice(k))+f:Bc()+e+f;try{t[g?"replaceState":"pushState"](v,"",m),o.value=v}catch(b){console.error(b),s[g?"replace":"assign"](m)}}function a(f,v){r(f,Oe({},t.state,Na(o.value.back,f,o.value.forward,!0),v,{position:o.value.position}),!0),i.value=f}function c(f,v){let g=Oe({},o.value,t.state,{forward:f,scroll:po()});r(g.current,g,!0),r(f,Oe({},Na(i.value,f,null),{position:g.position+1},v),!1),i.value=f}return{location:i,state:o,push:c,replace:a}}function $a(e){e=va(e);let t=Hc(e),s=zc(e,t.state,t.location,t.replace);function i(r,a=!0){a||s.pauseListeners(),history.go(r)}let o=Oe({locatLine truncated
|
||||
`)),e.options=Object.assign({},e.options,v.options),r.restore()})}a();let c=null;e.saveDefault=f=>{if(f&&f.preventDefault&&f.preventDefault(),e.saving)return;let v=r.checkpoint(),g={terms:e.terms.split(`
|
||||
`).map(k=>k.trim()).filter(k=>k.length>0),options:e.options};e.saving=!0,e.error=null,t.post("/api/user/default",g).then(()=>{r.accept(v),e.saving=!1,e.message=e.unsavedChanges?null:"Saved",c&&i.cancel(c),c=i(()=>{e.message=null},2500)},k=>{e.saving=!1;let b="ERRORS."+(k&&k.data&&k.data.error);s(b).then(C=>{e.error=C&&C!==b?C:"Unable to save your defaults. Please try again."},()=>{e.error="Unable to save your defaults. Please try again."})})},e.deleteAccount=()=>{e.deletingAccount||!e.confirmDeleteAccount||e.deleteUsername!==e.user?.username||(e.deletingAccount=!0,t.delete("/api/user").then(()=>{window.location.href="/"},()=>{e.deletingAccount=!1,e.deleteError="Unable to delete the account. Please try again."}))}},Wa=function(e,t,s){e.repoId=null,e.repoUrl=null,e.claim=()=>{t.post("/api/repo/claim",{repoId:e.repoId,repoUrl:e.repoUrl}).then(i=>{s.url("/dashboard")},i=>{e.error=i.data,e.claimForm.repoUrl.setValidity("not_found",!1),e.claimForm.repoId.setValidity("not_found",!1)})}},Ka=function(e,t,s,i,o){e.user&&!e.user.status&&s.url("/dashboard"),e.watch("user.status",()=>{e.user&&!e.user.status&&s.url("/dashboard")}),e.features=[{key:"anonymize",num:"01",eyebrow:"Anonymize",title:"Double-anonymous,",accent:"your rules.",text:"Choose what reviewers may see: links, images, PDFs, notebooks, GitHub Pages. Add your own terms, with regex if you need it, and pick the expiration date.",cta:"Start an anonymization",href:"/anonymize",url:"anonymous.4open.science/anonymize",img:"/imgs/anonymize.png",alt:"The anonymize form: source repository, terms to redact, options and a live README preview"},{key:"review",num:"02",eyebrow:"Review",title:"Reviewers browse",accent:"the real thing.",text:"Highlighted source code, rendered PDFs, images, and notebooks, in a familiar file explorer. GitHub Pages is also supported.",cta:"Open the example",href:"https://anonymous.4open.science/r/840c8c57-3c32-451e-bf12-0e20be300389/",target:"_self",url:"anonymous.4open.science/r/840c8c57-\u2026",img:"/imgs/explorer.png",alt:"The repository explorer with a file tree and a rendered README"},{key:"manage",num:"03",eyebrow:"Manage",title:"One dashboard,",accent:"until the decision.",text:"Monitor views, edit configuration, remove or update your repository. Program chairs can group submissions under a conference with one shared expiry.",cta:"Open the dashboard",href:"/dashboard",needsUser:!0,url:"anonymous.4open.science/dashboard",img:"/imgs/dashboard.png",alt:"The dashboard listing anonymized repositories with status, views and expiry"}],e.feature=e.features[0].key,e.selectFeature=function(c){e.feature=c},e.featureHref=function(c){return c.needsUser&&!e.user?"/signin":c.href},e.featureTarget=function(c){return c.needsUser&&!e.user?"_self":c.target||void 0},e.featureKeydown=function(c,f){let v={ArrowDown:1,ArrowRight:1,ArrowUp:-1,ArrowLeft:-1,Home:"first",End:"last"}[c.key];if(v===void 0)return;c.preventDefault();let g=e.features.length,k=v==="first"?0:v==="last"?g-1:(f+v+g)%g;e.feature=e.features[k].key,o(()=>{let m=i.document.getElementById("feature-tab-"+e.feature);m&&m.focus()})},e.cards=[{key:"repositories",total:0,label:"repositories anonymized"},{key:"users",total:0,label:"researchers"},{key:"pageViews",total:0,label:"page views"},{key:"pullRequests",total:0,label:"pull requests"}];function r(){t.get("/api/stat/").then(c=>{e.stat=c.data,e.cards[0].total=c.data.nbRepositories,e.cards[1].total=c.data.nbUsers,e.cards[2].total=c.data.nbPageViews,e.cards[3].total=c.data.nbPullRequests})}r();function a(c){let f={series:c,bars:[],viewW:100,deltaToday:0,pctChange:0,pctAbs:0,isUp:!0};if(!c||c.length<2)return f;let v=new Array(c.length-1);for(let E=1;E<c.length;E++)v[E-1]=c[E]-c[E-1];let g=v.length,k=Math.max.apply(null,v),m=Math.min.apply(null,v),b=Math.min(0,m),C=k-b||1;f.viewW=g*2,f.bars=new Array(g);for(let E=0;E<g;E++){let T=(v[E]-b)/C,D=Math.max(1.5,T*34);f.bars[E]={x:(E*2+.25).toFixed(2),y:(36-D).toFixed(2),w:"1.5",h:D.toFixed(2)}}if(f.deltaToday=v[g-1],g>=2){let E=v[g-2];E&&(f.pctChange=(f.deltaToday-E)/E*100)}return f.pctAbs=Math.round(Math.abs(f.pctChange)),f.isUp=f.pctChange>=0,f}e.history={repositories:a([]),users:a([]),pageViews:a([]),pullRequests:a([])},t.get("/api/stat/history?days=60").then(c=>{let f=c.data||[];e.history={repositories:a(f.map(v=>v.nbRepositories||0)),users:a(f.map(v=>v.nbUsers||0)),pageViews:a(f.map(v=>v.nbPageViews||0)),pullRequests:a(f.map(v=>v.nbPullRequests||0))}})},Ya=function(e,t,s,i,o,r){let a=ct();e.on("routeLeave",function(){$('[data-toggle="tooltip"]').tooltip("dispose")}),e.watch("user.status",()=>{e.user==null&&s.url("/")}),e.user==null&&s.url("/"),a.timeout(()=>{$('[data-toggle="tooltip"]').tooltip()},250),e.quotaExpanded=!1,e.closeQuota=()=>{e.quotaExpanded=!1,Ye(()=>document.querySelector(".dashboard-quota > summary")?.focus())},Jt()(document,"click",S=>{e.quotaExpanded&&!S.target.closest?.(".dashboard-quota")&&(e.quotaExpanded=!1)}),e.filtersExpanded=!1,e.projectView="all",e.selectedProject=null,e.p
Line truncated
|
||||
`)),e.options=Object.assign({},e.options,w.options),e.options.expirationDate=w.options&&w.options.expirationDate?new Date(w.options.expirationDate):M(),!i.repoId&&!i.pullRequestId&&!i.gistId&&!e.anonymize?.terms?.dirty&&(e.terms=e.defaultTerms),_&&_()})}function S(_,h,w){e.anonymize&&e.anonymize[_]&&e.anonymize[_].setValidity(h,w)}function I(_){try{let h=parseGithubUrl(_);if(h&&h.owner&&h.repo)return h.owner+"/"+h.repo}catch{}return null}e.reconnectingSource=!1,e.reconnectRepositoryId=void 0,e.reconnectSource=async()=>{if(confirm("Reconnect to the repository currently at this GitHub URL? Review its branch and commit before saving. Saving will replace the published snapshot.")){e.reconnectingSource=!0,e.reconnectRepositoryId=void 0,e.source.commit="",e.source.branch="",e.readme="",e.branches=[];try{await Y()}catch{}}};function H(){return e.reconnectingSource||!e.isUpdate||!e._originalRepositoryID?void 0:I(e.sourceUrl)===e._originalFullName?e._originalRepositoryID:void 0}async function K(){e._preservingDraft=!0;try{await e.urlSelected(!0)}finally{e._preservingDraft=!1}}async function X(){let _=new URLSearchParams(window.location.search).get("billingDraft");if(_){T.restore();try{let w=(await t.get("/api/billing/draft/"+encodeURIComponent(_))).data;if(w.path!==o.path())throw new Error("Wrong task");for(let R of["sourceUrl","terms","repoId","source","options","conference","githubConnection"])Object.prototype.hasOwnProperty.call(w.values,R)&&(e[R]=w.values[R]);return e.options?.expirationDate&&(e.options.expirationDate=new Date(e.options.expirationDate)),D=w.id,O=JSON.stringify(T.checkpoint()),e.draftRestored=!0,e.hostingReview=!0,e.sourceUrl&&await K(),o.replace?.(o.path()),!0}catch{e.billingError="The checkout draft could not be restored. Retry by reloading this page; any local draft is still available."}}let h;try{h=JSON.parse(sessionStorage.getItem("github-access-draft")||"null")}catch{return!1}if(!h||h.path!==o.path()||Date.now()-h.savedAt>=30*6e4)return T.restore()?(e.sourceUrl&&await K(),!0):!1;T.restore();for(let w of["sourceUrl","terms","repoId","pullRequestId","gistId","source","options","conference","githubConnection"])Object.prototype.hasOwnProperty.call(h.draft,w)&&(e[w]=h.draft[w]);return e.options.expirationDate&&(e.options.expirationDate=new Date(e.options.expirationDate)),await K(),sessionStorage.removeItem("github-access-draft"),!0}oe(()=>{!i.repoId&&!i.pullRequestId&&!i.gistId&&a(X,0),i.repoId&&i.repoId!=""&&(e.isUpdate=!0,e.detectedType="repo",e.repoId=i.repoId,t.get("/api/repo/"+e.repoId).then(async _=>{e.githubConnection=_.data.connection||"oauth",e.sourceUrl="https://github.com/"+_.data.source.fullName,e._originalFullName=_.data.source.fullName,e.terms=_.data.options.terms.filter(h=>h).join(`
|
||||
`),e.source=_.data.source,e.role=_.data.role||"owner",e._existingNever=_.data.options.expirationMode==="never",e.coauthors=_.data.coauthors||[],e._originalBranch=_.data.source.branch,e.options=Object.assign({},e.options,_.data.options),e.conference=_.data.conference,e._originalConference=_.data.conference,e.repositoryID=_.data.source.repositoryID,e._originalRepositoryID=_.data.source.repositoryID,_.data.options.expirationDate&&(e.options.expirationDate=new Date(_.data.options.expirationDate)),C=E(parseGithubUrl(e.sourceUrl)),!await X()&&(await Promise.all([Y(),ee()]),Se())},()=>{o.url("/404")})),i.pullRequestId&&i.pullRequestId!=""&&(e.isUpdate=!0,e.detectedType="pr",e.pullRequestId=i.pullRequestId,t.get("/api/pr/"+e.pullRequestId).then(async _=>{if(e.githubConnection=_.data.connection||"oauth",e.sourceUrl="https://github.com/"+_.data.source.repositoryFullName+"/pull/"+_.data.source.pullRequestId,e.terms=_.data.options.terms.filter(h=>h).join(`
|
||||
`),e.source=_.data.source,e.options=Object.assign({},e.options,_.data.options),e.conference=_.data.conference,e._originalConference=_.data.conference,_.data.options.expirationDate&&(e.options.expirationDate=new Date(_.data.options.expirationDate)),C=E(parseGithubUrl(e.sourceUrl)),!await X())try{await $e()}catch{}},()=>{o.url("/404")})),i.gistId&&i.gistId!=""&&(e.isUpdate=!0,e.detectedType="gist",e.gistId=i.gistId,t.get("/api/gist/"+e.gistId).then(async _=>{e.sourceUrl="https://gist.github.com/"+_.data.source.gistId,e.terms=_.data.options.terms.filter(h=>h).join(`
|
||||
`),e.source=_.data.source,e.options=Object.assign({},e.options,_.data.options),e.conference=_.data.conference,e._originalConference=_.data.conference,_.data.options.expirationDate&&(e.options.expirationDate=new Date(_.data.options.expirationDate)),C=E(parseGithubUrl(e.sourceUrl)),!await X()&&await Le()},()=>{o.url("/404")}))}),t.get("/github/connections").then(_=>{e.githubConnections=_.data,!i.repoId&&!i.pullRequestId&&!i.gistId&&e.githubConnection===void 0&&_.data.appEnabled&&_.data.appConnected&&(e.githubConnection="github-app",e.sourceUrl&&e.workspaceOpen&&K())}).catch(()=>{}),e.urlSelected=async(_=!1)=>{let h=++f,w;try{w=parseGithubUrl(e.sourceUrl.trim())}catch{e.sourceLoading=!1,re.cancel(),Ee.cancel(),U.cancel(),e.previewStatus="idle",e.sourceUrlError=e.sourceUrl?"Please provide a valid GitHub URL.":"",S("sourceUrl","github",!1);return}let R=E(w),P=_||C===R;C=R,e.sourceUrlError="",S("sourceUrl","github",!0);let L=document.activeElement?.id==="sourceUrl-landing";e.workspaceOpen=!0,L&&a(()=>document.getElementById("sourceUrl")?.focus(),0),e.reconnectingSource=!1,e.reconnectRepositoryId=void 0,!P&&!e.isUpdate&&(e.repoId="",e.pullRequestId="",e.gistId=""),e.details=null,e.branches=[],P||(e.source={type:"GitHubStream",branch:"",commit:""}),re.cancel(),Ee.cancel(),U.cancel(),Ne.clear(),qe.clear(),e.previewStatus="idle",e.anonymize_readme="",e.readme="",e.html_readme="",e.detectedType=null,e.gistOAuthRequired=!1,e.gistAppRequired=!1,e._preservingDraft=P;try{w.gistId&&!w.repo?(e.detectedType="gist",e.source={gistId:w.gistId},await Le()):w.pullRequestId?(e.detectedType="pr",e.source={repositoryFullName:w.owner+"/"+w.repo,pullRequestId:w.pullRequestId},await $e()):(e.detectedType="repo",await Promise.all([Y(),ee()]),Se())}catch{}finally{h===f&&(e._preservingDraft=!1)}$('[data-toggle="tooltip"]').tooltip()},$('[data-toggle="tooltip"]').tooltip(),e.watch("source.branch",async()=>{if(e.detectedType!=="repo")return;let _=e.branches.filter(w=>w.name==e.source.branch)[0];if(!_)return;!(!e.reconnectingSource&&e.isUpdate&&e._originalBranch===e.source.branch&&e.source.commit)&&!(e._preservingDraft&&e.source.commit)&&(e.source.commit=_.commit),e.readme=_.readme,await ee(),Se()}),e.getBranches=async _=>{let h=parseGithubUrl(e.sourceUrl),w=m();try{let R=await t.get(`/api/repo/${h.owner}/${h.repo}/branches`,{params:{reconnect:e.reconnectingSource?"1":void 0,anonymizedRepoId:e.isUpdate&&i.repoId&&I(e.sourceUrl)===e._originalFullName?i.repoId:void 0,connection:e.githubConnection,force:_===!0||e.reconnectingSource?"1":"0",repositoryID:H()}});if(!k(w))return;e.branches=R.data,e.sourceUnreachable=!1,e.source.branch||(e.source.branch=e.details.defaultBranch);let P=e.branches.filter(L=>L.name==e.source.branch);P.length>0&&(!(!e.reconnectingSource&&!_&&e.isUpdate&&!e.options.update&&e._originalBranch===e.source.branch&&e.source.commit)&&!(e._preservingDraft&&e.source.commit)&&(e.source.commit=P[0].commit),e.readme=P[0].readme,await ee(_))}catch(R){if(!k(w))return;e.branches=[],e.sourceUnreachable=R&&(R.status===404||R.data&&R.data.error==="repo_not_found");let P=R&&R.data&&R.data.error||(R&&R.status===404?"repo_not_found":"unknown_error");r("ERRORS."+P).then(L=>{e.toasts=e.toasts||[],e.addToast({title:"Error",date:new Date,body:L}),e.error=L},console.error),typeof S=="function"&&S("sourceUrl","missing",!1)}finally{b(w)}};async function Y(){let _=parseGithubUrl(e.sourceUrl),h=m();try{ie();let w=await t.get(`/api/repo/${_.owner}/${_.repo}/`,{params:{reconnect:e.reconnectingSource?"1":void 0,anonymizedRepoId:e.isUpdate&&i.repoId&&I(e.sourceUrl)===e._originalFullName?i.repoId:void 0,connection:e.githubConnection,repositoryID:H(),force:"1"}});if(!k(h))return;e.details=w.data,e.reconnectingSource&&(e.reconnectRepositoryId=e.details.externalId),e.details&&e.details.id&&(e.repositoryID=e.details.id),e.repoId||(e.repoId=e.details.repo+"-"+generateRandomId(4)),await e.getBranches(e.reconnectingSource)}catch(w){if(!k(h))return;throw w.data&&(r("ERRORS."+w.data.error).then(R=>{e.addToast({title:"Error",date:new Date,body:R}),e.error=R},console.error),Ce(w.data.error)),S("sourceUrl","missing",!1),w}finally{b(h)}}async function ee(_){if(e.readme&&!_)return e.readme;let h=parseGithubUrl(e.sourceUrl),w=m();try{let R=await t.get(`/api/repo/${h.owner}/${h.repo}/readme`,{params:{reconnect:e.reconnectingSource?"1":void 0,anonymizedRepoId:e.isUpdate&&i.repoId&&I(e.sourceUrl)===e._originalFullName?i.repoId:void 0,connection:e.githubConnection,force:_===!0?"1":"0",branch:e.source.branch,repositoryID:H()}});k(w,!0)&&(e.readme=R.data)}catch{k(w,!0)&&(e.readme="")}finally{b(w)}}function ce(){let _={terms:e.terms?e.terms.split(`
|
||||
`):[],image:!!e.options.image,link:!!e.options.link,repoId:e.repoId};e.source&&e.source.branch&&(_.branchName=e.source.branch);try{let h=parseGithubUrl(e.sourceUrl);_.repoName=`${h.owner}/${h.repo}`}catch{}return _}function le(_,h,w){let R=null,P=0,L=()=>{if(e.detectedType!==_)return;R&&a.cancel(R);let V=++P;e.previewStatus="loading",R=a(()=>{R=null;let B=h();if(!B){e.previewStatus="idle";return}t.post("/api/anonymize-preview",B).then(Q=>{V===P&&(w(Q.data,B),e.previewStatus="ready")}).catch(()=>{V===P&&(e.previewStatus="error")})},200)};return L.cancel=()=>{P++,R&&a.cancel(R),R=null},e.on("dispose",L.cancel),L}e.retryPreview=()=>{e.detectedType==="repo"&&re(),e.detectedType==="pr"&&Ee(),e.detectedType==="gist"&&U()};let re=le("repo",()=>e.readme?{content:e.readme,options:ce()}:null,_=>{if(typeof _?.content!="string")throw Error("Invalid preview response");e.anonymize_readme=_.content;let h="";try{let R=parseGithubUrl(e.sourceUrl),P=e.source.branch||e.details&&e.details.defaultBranch||"main";h=`https://github.com/${R.owner}/${R.repo}/raw/${P}/`}catch{}let w=renderMD(e.anonymize_readme,h);e.html_readme=w,a(Prism.highlightAll,150)});function Se(){!e.anonymize||!e.anonymize.terms||(e.termsRegexWarning=!!e.terms&&!!e.terms.match(/[-[\]{}()*+?.,\\^$|#]/g),re())}async function $e(){let _=parseGithubUrl(e.sourceUrl),h=m();try{ie();let w=await t.get(`/api/pr/${_.owner}/${_.repo}/${_.pullRequestId}`,{params:{connection:e.githubConnection}});if(!k(h))return;e.details=w.data,e.pullRequestId||(e.pullRequestId=_.repo+"-PR"+_.pullRequestId+"-"+generateRandomId(4))}catch(w){if(!k(h))return;throw w.data&&(r("ERRORS."+w.data.error).then(R=>{e.addToast({title:"Error",date:new Date,body:R}),e.error=R},console.error),Ce(w.data.error)),S("sourceUrl","missing",!1),w}finally{b(h)}}let Ne=ze(new Map);function ke(){let _=new Set,h=e.details&&e.details.pullRequest;if(!h)return _;typeof h.title=="string"&&_.add(h.title),typeof h.body=="string"&&_.add(h.body),typeof h.diff=="string"&&_.add(h.diff);let w=h.comments||[];for(let R of w)typeof R.author=="string"&&_.add(R.author),typeof R.body=="string"&&_.add(R.body);return _}let Ee=le("pr",()=>{let _=ke(),h=Array.from(_);return h.length===0?null:{contents:h,options:ce()}},(_,h)=>{if(!Array.isArray(_?.contents)||_.contents.length!==h.contents.length||_.contents.some(P=>typeof P!="string"))throw Error("Invalid preview response");let w=h.contents,R=new Map;for(let P=0;P<w.length&&P<_.contents.length;P++)R.set(w[P],_.contents[P]);Ne.clear(),R.forEach((P,L)=>Ne.set(L,P))});e.anonymizePrContent=function(_){return _&&(Ne.has(_)?Ne.get(_):"")};async function Le(){let _=parseGithubUrl(e.sourceUrl),h=m();try{ie(),e.gistOAuthRequired=!1,e.gistAppRequired=!1;let w=await t.get(`/api/gist/source/${_.gistId}`);if(!k(h))return;e.details=w.data,e.gistId||(e.gistId="gist-"+_.gistId.substring(0,6)+"-"+generateRandomId(4))}catch(w){if(!k(h))return;if(w.data?.error==="github_app_reconnect_required"){e.gistAppRequired=!0,S("sourceUrl","missing",!1);return}if(w.data?.error==="github_oauth_required"){e.gistOAuthRequired=!0,S("sourceUrl","missing",!1);return}throw w.data&&(r("ERRORS."+w.data.error).then(R=>{e.addToast({title:"Error",date:new Date,body:R}),e.error=R},console.error),Ce(w.data.error)),S("sourceUrl","missing",!1),w}finally{b(h)}}let qe=ze(new Map);function Ue(){let _=new Set,h=e.details&&e.details.gist;if(!h)return _;typeof h.description=="string"&&_.add(h.description),typeof h.ownerLogin=="string"&&_.add(h.ownerLogin);let w=h.files||[];for(let P of w)typeof P.filename=="string"&&_.add(P.filename),typeof P.content=="string"&&_.add(P.content);let R=h.comments||[];for(let P of R)typeof P.author=="string"&&_.add(P.author),typeof P.body=="string"&&_.add(P.body);return _}let U=le("gist",()=>{let _=Ue(),h=Array.from(_);return h.length===0?null:{contents:h,options:ce()}},(_,h)=>{if(!Array.isArray(_?.contents)||_.contents.length!==h.contents.length||_.contents.some(P=>typeof P!="string"))throw Error("Invalid preview response");let w=h.contents,R=new Map;for(let P=0;P<w.length&&P<_.contents.length;P++)R.set(w[P],_.contents[P]);qe.clear(),R.forEach((P,L)=>qe.set(L,P)),ne()});e.anonymizeGistContent=function(_){return _&&(qe.has(_)?qe.get(_):"")},e.previewGistFiles=[];function ne(){let _=e.details&&e.details.gist&&e.details.gist.files||[];e.previewGistFiles=_.map(h=>({filename:e.anonymizeGistContent(h.filename),content:e.anonymizeGistContent(h.content),language:h.language}))}e.watch("details.gist.files",ne,!0),e.watch("terms",ne);function x(){let _=e.conference;if(e.conference_data=null,!_)return;let h=e.isUpdate&&_===e._originalConference;t.get("/api/conferences/"+_).then(w=>{e.conference===_&&(e.conference_data=w.data,e.conference_data.startDate=new Date(e.conference_data.startDate),e.conference_data.endDate=new Date(e.conference_data.endDate),!h&&(e.options.expirationDate=new Date(e.conference_data.endDate),e.options.expirationMode="remove",e.options.update=e.conference_data.options.update,e.options.image=e.
Line truncated
|
||||
`).filter(L=>L),connection:e.githubConnection,fullName:`${w.owner}/${w.repo}`,repository:e.sourceUrl,reconnectRepositoryId:e.reconnectRepositoryId,options:e.options,source:e.source,conference:e.conference};e.details&&(R.options.pageSource=e.details.pageSource),ie();let P=e.isUpdate?"/api/repo/"+e.repoId:"/api/repo/";t.post(P,R,{headers:{"Content-Type":"application/json"}}).then(()=>{T.clear(),window.location.href="/status/"+e.repoId},L=>{L.data&&(r("ERRORS."+L.data.error).then(V=>{e.error=V},console.error),Ce(L.data.error),["quota_exceeded","subscription_required_for_never_expiration"].includes(L.data.error)&&(L.data.error==="subscription_required_for_never_expiration"&&(e._existingNever=!1),e.hostingQuotaBlocked=!0,e.hostingReview=!0,Ye(()=>document.getElementById("repository-hosting")?.scrollIntoView({block:"center"}))))}).finally(()=>{h.disabled=!1})},e.anonymizeGist=_=>{if(e.sourceLoading){e.error="Wait for the source to finish loading before saving.";return}if(ge())return;if(e.anonymize?.invalid||e.sourceUrlError){e.error="Please correct the highlighted fields before saving.";return}let h=_.currentTarget||_.target.closest?.("button")||_.target;h.disabled=!0;let w=parseGithubUrl(e.sourceUrl),R={gistId:e.gistId,terms:e.terms.trim().split(`
|
||||
`).filter(L=>L),source:{gistId:w.gistId},options:e.options,conference:e.conference};ie();let P=e.isUpdate?"/api/gist/"+e.gistId:"/api/gist/";t.post(P,R,{headers:{"Content-Type":"application/json"}}).then(()=>{T.clear(),window.location.href="/gist/"+e.gistId},L=>{L.data&&(r("ERRORS."+L.data.error).then(V=>{e.error=V},console.error),Ce(L.data.error))}).finally(()=>{h.disabled=!1})},e.anonymizePullRequest=_=>{if(e.sourceLoading){e.error="Wait for the source to finish loading before saving.";return}if(ge())return;if(e.anonymize?.invalid||e.sourceUrlError){e.error="Please correct the highlighted fields before saving.";return}let h=_.currentTarget||_.target.closest?.("button")||_.target;h.disabled=!0;let w=parseGithubUrl(e.sourceUrl),R={pullRequestId:e.pullRequestId,connection:e.githubConnection,terms:e.terms.trim().split(`
|
||||
`).filter(L=>L),source:{repositoryFullName:`${w.owner}/${w.repo}`,pullRequestId:w.pullRequestId},options:e.options,conference:e.conference};ie();let P=e.isUpdate?"/api/pr/"+e.pullRequestId:"/api/pr/";t.post(P,R,{headers:{"Content-Type":"application/json"}}).then(()=>{T.clear(),window.location.href="/pr/"+e.pullRequestId},L=>{L.data&&(r("ERRORS."+L.data.error).then(V=>{e.error=V},console.error),Ce(L.data.error))}).finally(()=>{h.disabled=!1})},e.watch("conference",()=>{x()}),e.watch("terms",()=>{e.detectedType==="repo"&&Se(),e.detectedType==="pr"&&Ee(),e.detectedType==="gist"&&U()}),e.watch("options.image",()=>{e.detectedType==="repo"&&Se(),e.detectedType==="pr"&&Ee(),e.detectedType==="gist"&&U()}),e.watch("options.link",()=>{e.detectedType==="repo"&&Se(),e.detectedType==="pr"&&Ee(),e.detectedType==="gist"&&U()}),e.watch("details",()=>{e.detectedType==="pr"&&Ee(),e.detectedType==="gist"&&U()},!0)},li=function(e,t,s,i,o,r){let a=ct(),c=Jt();e.on("dark-mode",(I,H)=>{e.aceOption&&(H?e.aceOption.theme="nord_dark":e.aceOption.theme="chrome")});let f=0,v=!1;e.on("dispose",()=>{v=!0,f++,g&&g.resolve()}),e.files=[],e.isMac=/Mac|iPhone|iPad|iPod/.test(navigator.platform||navigator.userAgent),e.fileSearchQuery="",e.fileSearchResults=null,e.fileSearchLoading=!1,c(document,"keydown",function(I){if((I.metaKey||I.ctrlKey)&&I.key==="k"){I.preventDefault();var H=document.querySelector(".tree-search-input");H&&(H.focus(),H.select())}});var g=null;e.onFileSearchChange=function(){g&&(g.resolve(),g=null);let I=e.fileSearchQuery;if(!I||I.length<2){e.fileSearchResults=null,e.fileSearchLoading=!1;return}e.fileSearchLoading=!0;let H=r.defer();g=H,t.get(`/api/repo/${e.repoId}/files/search?q=${encodeURIComponent(I)}`,{timeout:H.promise}).then(function(K){if(!(v||g!==H)){g=null,e.fileSearchLoading=!1;var X={};e.files.forEach(function(U){X[(U.path||"")+"/"+U.name]=!0});for(var Y=[],ee={},ce=0;ce<K.data.length;ce++){var le=K.data[ce];if(le.path)for(var re=le.path.split("/"),Se="",$e=0;$e<re.length;$e++){var Ne=Se;Se=Se?Se+"/"+re[$e]:re[$e];var ke=Ne+"/"+re[$e];!X[ke]&&!ee[ke]&&(ee[ke]=!0,Y.push({name:re[$e],path:Ne}))}}Y.sort(function(U,ne){return(U.path||"").split("/").length-(ne.path||"").split("/").length}),Y.length>0&&e.files.push.apply(e.files,Y);for(var Ee=[],Le=0;Le<K.data.length;Le++){var qe=K.data[Le],Ue=(qe.path||"")+"/"+qe.name;!X[Ue]&&qe.size!=null&&(Ee.push(qe),X[Ue]=!0)}Ee.length>0&&e.files.push.apply(e.files,Ee),e.fileSearchResults=K.data}},function(){!v&&g===H&&(g=null,e.fileSearchLoading=!1,e.fileSearchResults=[])})};let k={yml:"yaml",txt:"text",py:"python",js:"javascript",ts:"typescript"},m=["license","txt"],b=["png","jpg","jpeg","gif","svg","ico","bmp","tiff","tif","webp","avif","heif","heic"],C=["wav","mp3","ogg","wma","flac","aac","m4a"],E=["mp4","avi","webm","mov","mpg","mpeg","mkv","flv","wmv","3gp","3g2","m4v","f4v","f4p","f4a","f4b"];e.on("routeUpdate",function(I,H){if(e.repoId!=i.repoId)return S();if((i.path||"")!=e.filePath){e.filePath=i.path||"",e.paths=e.filePath.split("/").filter(K=>K&&K.trim().length>0),oe();for(let K=0;K<e.paths.length-1;K++){let X=K>0?e.paths.slice(0,K).join("/"):"";e.files.some(ee=>ee.path===X)||e.getFiles(X)}}});function T(){if(e.paths[0]!="")return;let I=["readme.md","readme.txt","readme.org","readme.1st","readme"],H={};for(let X of e.files)X.name.toLowerCase().indexOf("readme")>-1&&(H[X.name.toLowerCase()]=X.name);let K=null;for(let X of I)if(H[X]){K=X;break}if(!K&&Object.keys(H).length>0&&(K=Object.keys(H)[0]),K){let X=s.url();X[X.length-1]!="/"&&(X+="/"),s.url(X+encodePathForUrl(H[K]))}}e.fileCounts=null,e.getFiles=function(I){let H=e.repoId;return t.get(`/api/repo/${e.repoId}/files/?path=${encodeURIComponent(I)}&v=${e.options.lastUpdateDate}`).then(function(K){if(v||H!==e.repoId)return[];let X=I||"";return e.files=e.files.filter(Y=>Y.path!==X),e.files.push(...K.data),K.data},function(K){if(v||H!==e.repoId)return[];e.type="error",e.content=K&&K.data&&K.data.error||"unknown_error",e.files=[]})};function D(){let I=e.repoId;t.get(`/api/repo/${e.repoId}/files/counts`).then(function(H){v||I!==e.repoId||(e.fileCounts=H.data)},function(){e.fileCounts={}})}function O(){return e.files.filter(I=>I.name==e.paths[e.paths.length-1]&&I.path==e.paths.slice(0,e.paths.length-1).join("/"))[0]}var q=null;e.on("dispose",function(){q&&a.interval.cancel(q)});function F(I){if(v)return;let H=e.repoId;t.get(`/api/repo/${e.repoId}/options`).then(K=>{if(!(v||H!==e.repoId)){if(e.options=K.data,e.options.url){window.location=e.options.url;return}I&&I(K.data)}},K=>{if(!(v||H!==e.repoId)){var X=K.data||{};if(X.error==="rate_limited"&&X.resetAt){let ee=function(){var ce=Math.max(0,Math.ceil((e.rateLimitResetAt-Date.now())/1e3));if(ce<=0)e.rateLimitCountdown="",e.rateLimitResetAt=0,q&&(a.interval.cancel(q),q=null),F(I);else{var le=Math.floor(ce/60),re=ce%60;e.rateLimitCountdown=le>0?le+"m "+re+"s":re+"s"}};var Y=ee;e.type="rate_limited",e.rateLimitResetAt=X.resetAt,q&&a.interval.cancel(q),ee(),q=a.interval(ee,1e3
Line truncated
|
||||
`+E.join(`
|
||||
`)],{type:"text/csv"}),D=document.createElement("a");D.href=URL.createObjectURL(T),D.download=i.username+"-repositories.csv",D.click()},e.showStatusMessage=m=>{let b=m.statusMessage||"(no message)";window.prompt(`Status message for ${m.repoId} (${m.status}):`,b)},e.fetchGithubInfo=m=>{let b=window.open("","_blank");b&&b.document.write("<pre>Loading GitHub info for "+m.repoId+"...</pre>"),t.get("/api/admin/repos/"+m.repoId+"/github").then(C=>{b&&(b.document.open(),b.document.write('<pre style="font:13px monospace;padding:16px;white-space:pre-wrap">'+JSON.stringify(C.data,null,2).replace(/[<>]/g,E=>E==="<"?"<":">")+"</pre>"),b.document.close())},C=>{let E=C&&C.data?JSON.stringify(C.data,null,2):String(C);b&&(b.document.body.innerHTML='<pre style="color:#B42318;padding:16px">'+E+"</pre>")})};function f(m){t.get("/api/admin/users/"+m+"/repos",{}).then(b=>{e.repositories=b.data},b=>{console.error(b)})}function v(m){t.get("/api/admin/users/"+m,{}).then(b=>{e.userInfo=b.data},b=>{console.error(b)})}v(i.username),f(i.username),e.banUser=()=>{confirm(`Ban user ${i.username}?`)&&t.post(`/api/admin/users/${i.username}/ban`).then(()=>v(i.username),m=>console.error(m))},e.activateUser=()=>{t.post(`/api/admin/users/${i.username}/activate`).then(()=>v(i.username),m=>console.error(m))},e.promoteUser=()=>{confirm(`Promote ${i.username} to admin?`)&&t.post(`/api/admin/users/${i.username}/promote`).then(()=>v(i.username),m=>console.error(m))},e.demoteUser=()=>{confirm(`Remove admin privileges from ${i.username}?`)&&t.post(`/api/admin/users/${i.username}/demote`).then(()=>v(i.username),m=>console.error(m))},e.tokens=[],e.tokenForm={name:"",plaintext:null};function g(){t.get("/api/admin/tokens").then(m=>{e.tokens=m.data||[]},m=>{m.status!==401&&m.status!==403&&console.error(m)})}g(),e.createToken=()=>{e.tokenForm.name&&t.post("/api/admin/tokens",{name:e.tokenForm.name}).then(m=>{e.tokenForm.plaintext=m.data.token,e.tokenForm.name="",g()},m=>console.error(m))},e.revokeToken=m=>{confirm(`Revoke token "${m.name}"?`)&&t.delete("/api/admin/tokens/"+m.id).then(()=>g(),b=>console.error(b))},e.removeCache=m=>{confirm("Remove cached files for "+m.repoId+"?")&&t.delete("/api/admin/repos/"+m.repoId).then(()=>f(i.username),b=>console.error(b))},e.removeRepository=m=>{confirm("Remove repository "+m.repoId+"?")&&t.delete("/api/repo/"+m.repoId+"/").then(()=>f(i.username),b=>console.error(b))},e.updateRepository=m=>{let b=ze({title:`Refreshing ${m.repoId}...`,date:new Date,body:`The repository ${m.repoId} is going to be refreshed.`});e.toasts.push(b),t.post(`/api/repo/${m.repoId}/refresh`).then(C=>{C.data.status=="ready"?b.title=`${m.repoId} is refreshed.`:b.title=`Refreshing of ${m.repoId}.`},C=>{b.title=`Error during the refresh of ${m.repoId}.`,b.body=C.body})},e.getGitHubRepositories=m=>{t.get(`/api/user/${e.userInfo.username}/all_repositories`,{params:{force:"1"}}).then(b=>{e.userInfo.repositories=b.data})};let k=null;e.watch("query",()=>{o.timeout.cancel(k),k=o.timeout(()=>{f(i.username)},500)},!0)},il=function(e,t,s){let i=ct(),o=Jt();e.Math=Math,e.watch("user.status",()=>{e.user==null&&s.url("/")}),e.user==null&&s.url("/"),e.conferences=[],e.total=-1,e.totalPage=0,e.statusCounts=[];let r=C=>{if(C.key==="/"&&!["INPUT","TEXTAREA","SELECT"].includes(document.activeElement?.tagName)){C.preventDefault();let E=document.querySelector('.admin-filter-toolbar input[type="search"]');E&&E.focus()}};o(document,"keydown",r),e.on("dispose",()=>document.removeEventListener("keydown",r)),e.clearFilter=C=>{C==="dateRange"?(e.query.dateFrom="",e.query.dateTo=""):e.query[C]="",e.query.page=1},e.chips=[];let a=()=>{let C=[];(e.query.dateFrom||e.query.dateTo)&&C.push({key:"dateRange",label:"Date",value:(e.query.dateFrom||"\u2026")+" \u2013 "+(e.query.dateTo||"\u2026")}),e.chips=C};e.statusCountFor=C=>{let E=(e.statusCounts||[]).find(T=>T._id===C);return E?E.count:0},e.toggleSortDirection=()=>{e.query.direction=e.query.direction==="asc"?"desc":"asc"},e.sortBy=C=>{e.query.sort===C?e.query.direction=e.query.direction==="asc"?"desc":"asc":(e.query.sort=C,e.query.direction="desc"),e.query.page=1},e.sortIcon=C=>e.query.sort===C?e.query.direction==="asc"?"fa-arrow-up":"fa-arrow-down":"";let c="admin.conferences.filterPrefs",f={page:1,limit:25,sort:"name",direction:"asc",search:"",dateFrom:"",dateTo:"",ready:!1,expired:!1,removed:!1,error:!0,preparing:!0},v=loadFilterPrefs(c)||{};e.query=Object.assign({},f,v,{page:1,search:""});let g=s.search();g.search&&(e.query.search=g.search);let k="admin.conferences.presets";e.presets=JSON.parse(localStorage.getItem(k)||"[]"),e.savePreset=()=>{let C=window.prompt("Preset name:");if(!C)return;let E=Object.assign({},e.query);delete E.page,e.presets=(e.presets||[]).filter(T=>T.name!==C),e.presets.push({name:C,query:E}),localStorage.setItem(k,JSON.stringify(e.presets))},e.applyPreset=C=>{Object.assign(e.query,C.query,{page:1})},e.deletePreset=C=>{e.presets=(e.presets||[]).filter(E=>E.name!==C.name),localStorage.setItem(k,JS
Line truncated
|
||||
`).map((ce,le)=>le===0?ce:ee+ce).join(`
|
||||
`)}else typeof S=="number"||typeof S=="boolean"?Y=String(S):Y=JSON.stringify(S);se.push(`${K}${Y}${X}`)}),se.push("}"),se.join(`
|
||||
`)}function v(D){let O=[],q="",F=/(\w+):(>=|<=|!=|>|<|=)?([^\s]+)/g,M=0,z;for(;z=F.exec(D);)q+=D.slice(M,z.index),M=F.lastIndex,O.push({key:z[1],op:z[2]||"=",val:z[3]});return q+=D.slice(M),{filters:O,free:q.trim().toLowerCase()}}function g(D,O){for(let q of O.filters){let F=(z,se,oe)=>{let S=parseFloat(z),I=parseFloat(se);return oe==="="?String(z)===String(se):oe==="!="?String(z)!==String(se):oe===">="?S>=I:oe==="<="?S<=I:oe===">"?S>I:oe==="<"?S<I:!0},M;if(q.key==="code")M=D.displayMessage;else if(q.key==="module")M=D.module;else if(q.key==="status")M=D._status;else if(q.key==="url")M=D._url;else if(q.key==="repo")M=D._repoId;else if(q.key==="level")M=D.level;else continue;if(M==null||!F(M,q.val,q.op))return!1}return!(O.free&&((D.displayMessage||"")+" "+(D.module||"")+" "+(D._url||"")+" "+JSON.stringify(D.raw||[])).toLowerCase().indexOf(O.free)===-1)}function k(){let D=v(e.query.search||"");e.parsedFilterCount=D.filters.length;let O=e.query.bucket,q=e.entries.filter(M=>O&&M._bucket!==O?!1:g(M,D)),F=e.query.group;if(F){let M=oe=>F==="module"?oe.module:oe.displayMessage||oe.message||"_",z=new Map;for(let oe of q){let S=M(oe);if(z.has(S)){let I=z.get(S);I.count++,I._related.push(oe),new Date(oe.ts)>new Date(I.ts)&&(I.ts=oe.ts,I._url=oe._url,I._status=oe._status),new Date(oe.ts)<new Date(I._firstSeen)&&(I._firstSeen=oe.ts)}else{let I=Object.assign({},oe);I._key=`${F}:${S}`,I._related=[oe],I._firstSeen=oe.ts,I._lastHourCount=0,I.count=1,z.set(S,I)}}let se=Date.now()-3600*1e3;for(let oe of z.values())oe._lastHourCount=oe._related.filter(S=>new Date(S.ts).getTime()>=se).length;q=Array.from(z.values())}else q=q.map((M,z)=>(M._key="row:"+z+":"+M.ts,M._related=[M],M._firstSeen=M.ts,M._lastHourCount=0,M.count=1,M));e.query.sort==="count"?q.sort((M,z)=>z.count-M.count||new Date(z.ts)-new Date(M.ts)):q.sort((M,z)=>new Date(z.ts)-new Date(M.ts)),e.visible=q}function m(D){let O=D?e.entries.length:0,q=D?e.pageSize:Math.max(e.pageSize,e.entries.length||e.pageSize);t.get("/api/admin/errors",{params:{offset:O,limit:q}}).then(F=>{let M=(F.data.entries||[]).map(c);e.entries=D?e.entries.concat(M):M,e.available=!!F.data.available,e.cap=F.data.max||e.cap,e.total=F.data.total||e.entries.length,k()},F=>console.error(F))}e.loadMore=()=>m(!0),e.canLoadMore=()=>e.entries.length<e.total;function b(){t.get("/api/admin/errors/stats").then(D=>{let O=D.data||{},q=O.prev24h?Math.round((O.last24h-O.prev24h)/O.prev24h*100):0;e.stats={last24h:O.last24h||0,prev24h:O.prev24h||0,delta:q,severity:O.severity||{error:0,warn:0,info:0},unique:O.unique||{error:0,warn:0,info:0},buckets:O.buckets||[],dropped:O.dropped||0}},D=>console.error(D))}function C(){m(),b()}e.barPx=(D,O)=>{let q=e.stats.buckets||[],F=0;for(let oe of q)F=Math.max(F,(oe.error||0)+(oe.warn||0)+(oe.info||0));if(!F)return 0;let M=(D.error||0)+(D.warn||0)+(D.info||0);if(!M)return 0;let z=Math.round(M/F*60),se=D[O]||0;return Math.round(se/M*z)},e.bucketTitle=D=>`${new Date(D.hour).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})} \xB7 ${D.error||0} err \xB7 ${D.warn||0} warn \xB7 ${D.info||0} info`,e.toggle=D=>{e.expanded[D._key]=!e.expanded[D._key]},e.setBucket=D=>{e.query.bucket=D},e.refreshNow=C,e.clearAll=()=>{confirm("Clear all captured errors?")&&t.delete("/api/admin/errors").then(C,D=>console.error(D))},e.exportCsv=()=>{let D=["ts","level","module","displayMessage","_status","_url","_repoId"],O=[D.join(",")];for(let z of e.visible)O.push(D.map(se=>{let oe=z[se]==null?"":String(z[se]);return/[",\n]/.test(oe)?`"${oe.replace(/"/g,'""')}"`:oe}).join(","));let q=new Blob([O.join(`
|
||||
`)],{type:"text/csv;charset=utf-8"}),F=URL.createObjectURL(q),M=document.createElement("a");M.href=F,M.download=`errors-${new Date().toISOString().slice(0,19)}.csv`,document.body.appendChild(M),M.click(),document.body.removeChild(M),URL.revokeObjectURL(F)};function E(D){e.copyHint=`${D} copied`,o.timeout(()=>{e.copyHint=""},1500)}e.copyJson=D=>{navigator.clipboard.writeText(D._detailJson).then(()=>E("JSON"))},e.copyCurl=D=>{if(!D._url)return;let q=`curl -X ${D._method||"GET"} '${window.location.origin}${D._url}'`;navigator.clipboard.writeText(q).then(()=>E("curl"))},C();let T=i(()=>{e.query.autoRefresh&&C()},15e3);e.on("dispose",()=>i.cancel(T)),e.watch("query.search",k),e.watch("query.bucket",k),e.watch("query.sort",k),e.watch("query.group",k)},ll=function(e,t,s,i){if(e.Math=Math,e.watch("user.status",()=>{e.user==null&&s.url("/")}),e.user==null){s.url("/");return}e.data=null,e.loading=!0,e.error=null,e.performance=null,e.performanceMinutes=15,e.performanceTab="routes",e.performanceError=null,e.latencyBound=function(b,C){return b.count?b[C]==null?"> 60 s":"\u2264 "+b[C]+" ms":"No samples"},e.firstByteBound=function(b){return e.latencyBound({count:b.firstByteCount,p95UpperMs:b.firstByteP95UpperMs},"p95UpperMs")},e.sampleAge=function(b){return Math.max(0,Math.round((Date.now()-b.sampledAt)/1e3))},e.peakRss=function(b){return(e.performance.runtimeSeries||[]).filter(function(C){return C.instance===b.instance}).reduce(function(C,E){return Math.max(C,E.rss)},b.memory.rss)},e.performanceWarning=function(b){return e.sampleAge(b)>45||b.droppedBatches||b.droppedMetrics||b.sockets?.closeWait>0||b.eventLoop.p95Ms>100||b.memoryLimitBytes&&b.memory.rss>b.memoryLimitBytes*.8};var o=!1;e.loadPerformance=function(){var b=e.performanceMinutes;e.performance?.windowMinutes&&e.performance.windowMinutes!==b&&(e.performance=null),!o&&(o=!0,t.get("/api/admin/performance",{params:{minutes:b}}).then(function(C){b===e.performanceMinutes&&(e.performance=C.data,e.performanceError=C.data.available?null:"Performance monitoring is unavailable. Request history may be incomplete.")},function(){b===e.performanceMinutes&&(e.performanceError="Could not refresh performance monitoring. Displayed samples may be stale.")}).finally(function(){o=!1,b!==e.performanceMinutes&&e.loadPerformance()}))};function r(b){if(b==null)return"\u2014";for(var C=["B","KB","MB","GB","TB"],E=0,T=b;T>=1024&&E<C.length-1;)T/=1024,E++;return T.toFixed(E>0?1:0)+" "+C[E]}e.humanBytes=r;function a(b){if(!b)return"\u2014";var C=Math.floor(b/86400),E=Math.floor(b%86400/3600),T=Math.floor(b%3600/60);return C>0?C+"d "+(E<10?"0":"")+E+"h":E>0?E+"h "+(T<10?"0":"")+T+"m":T+"m"}e.humanDuration=a;function c(b){return b==null?"\u2014":b>=1e6?(b/1e6).toFixed(1)+"M":b>=1e3?(b/1e3).toFixed(1)+"K":String(b)}e.humanNum=c,e.queueTotal=function(b){return b?(b.waiting||0)+(b.active||0)+(b.delayed||0)+(b.failed||0):0},e.statusCount=function(b){if(!e.data||!e.data.repos)return 0;for(var C=e.data.repos.statusBreakdown||[],E=0;E<C.length;E++)if(C[E]._id===b)return C[E].count;return 0},e.barPct=function(b){var C=e.data&&e.data.repos?e.data.repos.total:0;if(!C)return 0;var E=[b];b==="expired"&&E.push("expiring"),b==="removed"&&E.push("removing"),b==="preparing"&&E.push("download");var T=0;return E.forEach(function(D){T+=e.statusCount(D)}),Math.max(.4,T/C*100)},e.errPct=function(b){if(!e.data||!e.data.errors)return 0;var C=Math.max(e.data.errors.severity.error,e.data.errors.severity.warn,e.data.errors.severity.info,1);return e.data.errors.severity[b]/C*100};function f(b){var C=b||[];return C.map(function(E,T){var D=C[T-1]||{},O=Object.assign({},E);return O.dailyRepositories=T?Math.max(0,(E.nbRepositories||0)-(D.nbRepositories||0)):0,O.dailyUsers=T?Math.max(0,(E.nbUsers||0)-(D.nbUsers||0)):0,O.dailyPageViews=T?Math.max(0,(E.nbPageViews||0)-(D.nbPageViews||0)):0,O})}function v(b){var C=b&&b.length?b[b.length-1]:{};return{repositories:C.dailyRepositories||0,users:C.dailyUsers||0,pageViews:C.dailyPageViews||0}}var g={};e.historyBarH=function(b,C){return!b||!g[C]?0:Math.max(1,Math.round(b[C]/g[C]*140))},e.historyLabel=function(b){if(!b||!b.date)return"";var C=new Date(b.date);return C.getUTCMonth()+1+"/"+C.getUTCDate()};function k(){e.loadPerformance(),t.get("/api/admin/overview").then(function(b){b.data.history=f(b.data.history),b.data.daily={today:v(b.data.history)},e.data=b.data,e.loading=!1,e.error=null,g={},(b.data.history||[]).forEach(function(C){["dailyPageViews","dailyRepositories","dailyUsers","nbUsers"].forEach(function(E){(!g[E]||C[E]>g[E])&&(g[E]=C[E])})})},function(b){e.loading=!1,e.error=b.data&&b.data.error||"Failed to load overview"})}k();var m=i(k,3e4);e.on("dispose",function(){i.cancel(m)})};var dl=[{path:"/connections",template:"partials/connections.htm",title:"GitHub connections \u2013 Anonymous GitHub",preserveExplorer:!1,setup:(e,t)=>el(e,t.http)},{path:"/signin",template:"partials/signin.htm",title:"Sign in \u2013 Anonymous GitHub",preserveExplorer:!1,setup:(e,t)=>tl(e,t.http)}
Line truncated
|
||||
anonymous_github`)])])])])]),p(" Limitations "),n("h4",{class:"faq-cat"},[n("svg",{class:"ui-icon mr-2","aria-hidden":"true"},[n("use",{href:"#ui-icon-warning"})]),y("Limitations ")]),n("div",{class:"panel-group",id:"faq-limitations",role:"tablist","aria-multiselectable":"true"},[n("div",{class:"panel panel-default mb-3"},[n("div",{class:"panel-heading p-3",role:"tab",id:"headingLimitation"},[n("h3",{class:"panel-title"},[n("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-limitations",href:"#limitation","aria-expanded":"false","aria-controls":"limitation"}," What are the limitations of Anonymous GitHub? ")])]),n("div",{id:"limitation",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingLimitation"},[n("div",{class:"panel-body p-3"},[n("ul",null,[n("li",null," Anonymous GitHub only anonymizes text-based files. Binary files (compiled executables, archives, etc.) are served as-is without anonymization. "),n("li",null," Files larger than 8 MB are not supported. "),n("li",null," Static site generators (such as Jekyll) used with GitHub Pages are not fully supported, although Markdown files are converted to HTML. "),n("li",null," The anonymization of terms within source code may change the behavior of the program (e.g., if a replaced term appears in a string literal or identifier). ")])])])])]),p(" Privacy & Security "),n("h4",{class:"faq-cat"},[n("svg",{class:"ui-icon mr-2","aria-hidden":"true"},[n("use",{href:"#ui-icon-shield"})]),y("Privacy & Security ")]),n("div",{class:"panel-group",id:"faq-privacy",role:"tablist","aria-multiselectable":"true"},[n("div",{class:"panel panel-default mb-3"},[n("div",{class:"panel-heading p-3",role:"tab",id:"headingData"},[n("h3",{class:"panel-title"},[n("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-privacy",href:"#data","aria-expanded":"false","aria-controls":"data"}," How is my data handled? ")])]),n("div",{id:"data",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingData"},[n("div",{class:"panel-body p-3"},[n("p",null," Repository data is used to provide the anonymization service. If you purchase a plan, Stripe processes payment and billing information. When a repository is removed or expires, only its configuration is retained \u2014 this makes it easy to restore the repository if needed and ensures that no future repository reuses the same ID. ")])])]),n("div",{class:"panel panel-default mb-3"},[n("div",{class:"panel-heading p-3",role:"tab",id:"headingPermissions"},[n("h3",{class:"panel-title"},[n("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-privacy",href:"#permissions","aria-expanded":"false","aria-controls":"permissions"}," Why does GitHub say Anonymous GitHub asks for write access? ")])]),n("div",{id:"permissions",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingPermissions"},[n("div",{class:"panel-body p-3"},[n("p",null,[y("The GitHub App has read-only access to the repositories you choose. You can change repository access on your "),n("a",{href:"/connections"},"GitHub connections"),y(" page.")]),n("p",null,"Legacy GitHub OAuth includes write access to private repositories because GitHub's OAuth scopes do not offer a read-only option for them. Existing OAuth anonymizations and gists can still use this connection."),n("p",null,"Anonymous GitHub reads repository content. It never pushes, modifies, or deletes files on GitHub.")])])]),n("div",{class:"panel panel-default mb-3"},[n("div",{class:"panel-heading p-3",role:"tab",id:"headingViewer"},[n("h3",{class:"panel-title"},[n("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-privacy",href:"#viewer","aria-expanded":"false","aria-controls":"viewer"}," Can repository owners see who viewed their anonymized repository? ")])]),n("div",{id:"viewer",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingViewer"},[n("div",{class:"panel-body p-3"},[n("p",null," No. Only the total number of views is tracked as an incremental counter. It is not possible for the repository owner or for Anonymous GitHub to identify individual viewers. Reviewer anonymity is fully preserved. ")])])])]),p(" Self-Hosting "),n("h4",{class:"faq-cat"},[n("svg",{class:"ui-icon mr-2","aria-hidden":"true"},[n("use",{href:"#project-icon-storage"})]),y("Self-Hosting ")]),n("div",{class:"panel-group",id:"faq-hosting",role:"tablist","aria-multiselectable":"true"},[n("div",{class:"panel panel-default mb-3"},[n("div",{class:"panel-heading p-3",role:"tab",id:"headingDeploy"},[n("h3",{class:"panel-title"},[n("a",{class:"collapsed",role:"button","data-toggle":"collapse","data-parent":"#faq-hosting",href:"#deploy","aria-expanded":"false","aria-controls":"deploy"}," Can I deploy my own instance? ")])]),n("div",{id:"deploy",class:"panel-collapse collapse",role:"tabpanel","aria-labelledby":"headingDeploy"},[n("div",{class:"panel-body p-3"},[n("p",null,[y(
Line truncated
|
||||
anonymized repository often think it is about the paper, so the
|
||||
label names the service and the tooltip says who to contact. `),t[12]||(t[12]=o("li",{class:"nav-item d-none d-lg-block"},[o("a",{class:"nav-link",target:"_blank",rel:"noopener",href:"https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml",title:"Report a problem with the Anonymous GitHub service. Questions about an anonymized repository or paper go to its authors.","data-offset":"30"},[o("svg",{class:"ui-icon d-lg-none mr-1","aria-hidden":"true"},[o("use",{href:"#ui-icon-bug"})]),b("Report a bug ")])],-1)),e.isDarkMode?p("v-if",!0):(l(),d("li",Y4,[o("a",{class:"nav-link nav-icon",href:"#",title:"Switch to dark mode","aria-label":"Switch to dark mode",role:"button",onClick:t[0]||(t[0]=me(n=>{e.darkMode(!0)},["prevent"]))},[...t[7]||(t[7]=[o("svg",{class:"ui-icon","aria-hidden":"true"},[o("use",{href:"#ui-icon-moon"})],-1)])])])),e.isDarkMode?(l(),d("li",J4,[o("a",{class:"nav-link nav-icon",href:"#",title:"Switch to light mode","aria-label":"Switch to light mode",role:"button",onClick:t[1]||(t[1]=me(n=>{e.darkMode(!1)},["prevent"]))},[...t[8]||(t[8]=[o("svg",{class:"ui-icon","aria-hidden":"true"},[o("use",{href:"#ui-icon-sun"})],-1)])])])):p("v-if",!0),e.user?p("v-if",!0):(l(),d("li",Q4,[...t[9]||(t[9]=[o("a",{class:"nav-link btn-signin",target:"_self",href:"/signin","data-offset":"30"},[o("i",{class:"fab fa-github mr-1"}),b(" Sign in ")],-1)])])),e.user?(l(),d("li",X4,[o("a",Z4,[e.user?.photo?(l(),d("img",{key:0,width:"22",height:"22",class:"rounded-circle",src:e.safeUrl(e.user?.photo)},null,8,x4)):p("v-if",!0),o("span",e3,c(e.user?.username),1)]),o("div",t3,[o("h6",o3,"Signed in as @"+c(e.user?.username),1),t[10]||(t[10]=ce('<a class="dropdown-item" href="/profile"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-filter"></use></svg><span>Settings<small>Defaults, quotas, account</small></span></a><a class="dropdown-item" href="/connections"><i class="fab fa-github" aria-hidden="true"></i><span>GitHub connections<small>Repository access and OAuth</small></span></a><a class="dropdown-item" href="/claim"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-link"></use></svg><span>Claim an anonymization<small>Attach an existing mirror to your account</small></span></a><div class="dropdown-divider"></div><a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-bug"></use></svg><span>Report a bug<small>About this service, not a paper</small></span></a><a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/" target="_blank" rel="noopener"><i class="fab fa-github" aria-hidden="true"></i><span>Source code</span></a><a class="dropdown-item" href="https://ko-fi.com/tdurieux" target="_blank" rel="noopener"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-heart"></use></svg><span>Support the project</span></a><div class="dropdown-divider"></div><a class="dropdown-item" href="/api/user/logout" target="_self"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-logout"></use></svg><span>Sign out</span></a>',9))])])):p("v-if",!0)])])],2),e.generalMessage?(l(),d("div",{key:0,class:"navbar shadow generalMessage",innerHTML:e.sanitize(e.generalMessage)},null,8,s3)):p("v-if",!0)],64)}var n3={class:"paper-landing"},r3={class:"paper-hero"},i3={class:"paper-hero-copy"},a3={class:"paper-cta-row"},l3={key:0,href:"/signin",target:"_self",class:"btn-hero"},d3={key:1,href:"/anonymize",class:"btn-hero"},u3={class:"paper-stats paper-wrap",id:"metrics","aria-label":"Usage over the last 60 days"},c3={class:"paper-stats-grid"},p3={class:"paper-stat-value"},f3={class:"paper-stat-label"},m3=["viewBox"],h3=["x","y","width","height"],v3={key:1,class:"paper-stat-delta"},g3={class:"paper-features paper-wrap","aria-label":"Product features"},y3={class:"paper-features-list",role:"tablist","aria-label":"Features","aria-orientation":"vertical"},b3=["onClick","onKeydown","id","aria-controls","aria-selected","tabindex"],w3={class:"paper-eyebrow"},k3={class:"paper-feature-title"},E3={class:"paper-feature-more"},C3={class:"paper-feature-title paper-feature-title-m","aria-hidden":"true"},_3=["href","target"],S3={class:"paper-features-stage"},N3=["id","aria-labelledby"],D3={class:"paper-frame"},R3={class:"paper-frame-bar","aria-hidden":"true"},T3={class:"url"},A3=["src","alt"],O3={class:"paper-footer"},I3={class:"paper-footer-inner"},P3={class:"paper-footer-col"},V3={key:0,href:"/dashboard"},q3={key:1,href:"/conferences"},M3={class:"paper-footer-foot"};function Dl(e,t){return l(),d("div",n3,[p(" Hero: copy on the left, the product on the right "),o("section",r3,[o("div",i3,[t[2]||(t[2]=o("div",{class:"paper-eyebrow"},"For double-blind peer review",-1)),t[3]||(t[3]=o("h1",{class:"hero-title"},[b(" Share the code,"),o("br"),o("span",{class:"accent"},"not the author.")],-1)),t[4]||(t[4]=o("p",{class:"hero-subtitle
Line truncated
|
||||
label names the service and the tooltip says who to contact. `),t[13]||(t[13]=n("li",{class:"nav-item d-none d-lg-block"},[n("a",{class:"nav-link",target:"_blank",rel:"noopener",href:"https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml",title:"Report a problem with the Anonymous GitHub service. Questions about an anonymized repository or paper go to its authors.","data-offset":"30"},[n("svg",{class:"ui-icon d-lg-none mr-1","aria-hidden":"true"},[n("use",{href:"#ui-icon-bug"})]),y("Report a bug ")])],-1)),e.isDarkMode?p("v-if",!0):(l(),d("li",SI,[n("a",{class:"nav-link nav-icon",href:"#",title:"Switch to dark mode","aria-label":"Switch to dark mode",role:"button",onClick:t[0]||(t[0]=ue(s=>{e.darkMode(!0)},["prevent"]))},[...t[8]||(t[8]=[n("svg",{class:"ui-icon","aria-hidden":"true"},[n("use",{href:"#ui-icon-moon"})],-1)])])])),e.isDarkMode?(l(),d("li",NI,[n("a",{class:"nav-link nav-icon",href:"#",title:"Switch to light mode","aria-label":"Switch to light mode",role:"button",onClick:t[1]||(t[1]=ue(s=>{e.darkMode(!1)},["prevent"]))},[...t[9]||(t[9]=[n("svg",{class:"ui-icon","aria-hidden":"true"},[n("use",{href:"#ui-icon-sun"})],-1)])])])):p("v-if",!0),e.user?p("v-if",!0):(l(),d("li",DI,[...t[10]||(t[10]=[n("a",{class:"nav-link btn-signin",target:"_self",href:"/signin","data-offset":"30"},[n("i",{class:"fab fa-github mr-1"}),y(" Sign in ")],-1)])])),e.user?.username&&e.headerCanUpgrade&&!e.headerBillingPending?(l(),d("li",RI,[n("a",{class:"nav-link nav-upgrade",href:"/profile?upgrade=1",onClick:t[2]||(t[2]=ue(s=>e.openHeaderUpgrade(),["prevent"]))},"Upgrade")])):p("v-if",!0),e.user?(l(),d("li",TI,[n("a",AI,[e.user?.photo?(l(),d("img",{key:0,width:"22",height:"22",class:"rounded-circle",src:e.safeUrl(e.user?.photo)},null,8,OI)):p("v-if",!0),n("span",PI,u(e.user?.username),1)]),n("div",II,[n("h6",VI,"Signed in as @"+u(e.user?.username),1),t[11]||(t[11]=pe('<a class="dropdown-item" href="/profile"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-filter"></use></svg><span>Settings<small>Defaults, quotas, account</small></span></a><a class="dropdown-item" href="/connections"><i class="fab fa-github" aria-hidden="true"></i><span>GitHub connections<small>Repository access and OAuth</small></span></a><a class="dropdown-item" href="/claim"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-link"></use></svg><span>Claim an anonymization<small>Attach an existing mirror to your account</small></span></a><div class="dropdown-divider"></div><a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-bug"></use></svg><span>Report a bug<small>About this service, not a paper</small></span></a><a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/" target="_blank" rel="noopener"><i class="fab fa-github" aria-hidden="true"></i><span>Source code</span></a><a class="dropdown-item" href="https://ko-fi.com/tdurieux" target="_blank" rel="noopener"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-heart"></use></svg><span>Support the project</span></a><div class="dropdown-divider"></div><a class="dropdown-item" href="/api/user/logout" target="_self"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-logout"></use></svg><span>Sign out</span></a>',9))])])):p("v-if",!0)])])],2),e.generalMessage?(l(),d("div",{key:0,class:"navbar shadow generalMessage",innerHTML:e.sanitize(e.generalMessage)},null,8,qI)):p("v-if",!0)],64)}var MI={class:"paper-landing"},$I={class:"paper-hero"},LI={class:"paper-hero-copy"},UI={class:"paper-cta-row"},FI={key:0,href:"/signin",target:"_self",class:"btn-hero"},BI={key:1,href:"/anonymize",class:"btn-hero"},zI={class:"paper-stats paper-wrap",id:"metrics","aria-label":"Usage over the last 60 days"},HI={class:"paper-stats-grid"},jI={class:"paper-stat-value"},GI={class:"paper-stat-label"},WI=["viewBox"],KI=["x","y","width","height"],YI={key:1,class:"paper-stat-delta"},QI={class:"paper-features paper-wrap","aria-label":"Product features"},JI={class:"paper-features-list",role:"tablist","aria-label":"Features","aria-orientation":"vertical"},XI=["onClick","onKeydown","id","aria-controls","aria-selected","tabindex"],ZI={class:"paper-eyebrow"},xI={class:"paper-feature-title"},e4={class:"paper-feature-more"},t4={class:"paper-feature-title paper-feature-title-m","aria-hidden":"true"},n4=["href","target"],o4={class:"paper-features-stage"},s4=["id","aria-labelledby"],i4={class:"paper-frame"},r4={class:"paper-frame-bar","aria-hidden":"true"},a4={class:"url"},l4=["src","alt"],d4={class:"paper-footer"},u4={class:"paper-footer-inner"},c4={class:"paper-footer-col"},p4={key:0,href:"/dashboard"},f4={key:1,href:"/conferences"},h4={class:"paper-footer-foot"};function Al(e,t){return l(),d("div",MI,[p(" Hero: copy on the left, the product on the right "),n("section",$I,[n("div",LI,[t[2]||(t[
Line truncated
|
||||
MIT CSAIL
|
||||
jane.smith@example.org`},N5={id:"termsHelp",class:"form-text text-muted"},D5={class:"invalid-feedback"},R5={id:"settings-display",class:"paper-settings-section"},T5={class:"form-group paper-check-list"},A5={class:"form-check"},O5={class:"form-check-input",type:"checkbox",id:"link",name:"link"},I5={class:"form-check"},P5={class:"form-check-input",type:"checkbox",id:"image",name:"image"},V5={class:"form-check"},q5={class:"form-check-input",type:"checkbox",id:"pdf",name:"pdf"},M5={class:"form-check"},L5={class:"form-check-input",type:"checkbox",id:"notebook",name:"notebook"},$5={id:"settings-features",class:"paper-settings-section"},U5={class:"form-group paper-check-list"},F5={class:"form-check"},z5={class:"form-check-input",type:"checkbox",id:"page",name:"page"},H5={class:"form-check"},B5={class:"form-check-input",type:"checkbox",id:"loc",name:"loc"},j5={class:"form-check"},G5={class:"form-check-input",type:"checkbox",id:"update",name:"update"},W5={class:"form-group"},K5={class:"form-control",id:"mode",name:"mode"},Y5={class:"form-text text-muted"},J5={id:"settings-expiration",class:"paper-settings-section"},Q5={class:"form-group"},X5={class:"form-control",id:"expiration",name:"expiration"},Z5={key:0,class:"draft-status",role:"status"},x5={key:1,class:"draft-status"},eq={class:"paper-settings-footer"},tq={key:0,class:"paper-inline-alert paper-inline-alert-error",role:"alert"},oq=["textContent"],sq={class:"paper-settings-actions"},nq=["disabled"],rq={key:0,class:"ui-icon mr-1","aria-hidden":"true"},iq={id:"settings-account",class:"paper-settings-section"},aq={class:"paper-account-card"},lq=["src"],dq={class:"paper-account-identity"},uq={class:"paper-account-name"},cq={class:"paper-danger-zone"},pq={key:0,class:"paper-inline-alert paper-inline-alert-error",role:"alert"},fq=["textContent"],mq=["disabled"];function Il(e,t){let n=We("quota-usage"),r=be("paper-scrollspy"),s=be("field"),i=be("form");return l(),d("div",v5,[t[52]||(t[52]=ce('<div class="paper-crumbs"><a href="/dashboard">My work</a> \xA0/\xA0 <span class="here">Settings</span></div><h1 class="paper-page-title">Your <em>settings</em></h1><p class="paper-page-lede">Defaults applied to every new anonymization, your quotas, and your account.</p>',3)),o("div",g5,[N((l(),d("aside",y5,[...t[4]||(t[4]=[ce('<div class="paper-settings-toc-head">On this page</div><nav><a href="#settings-quota">Quota</a><a href="#settings-terms">Terms to redact</a><a href="#settings-display">Display</a><a href="#settings-features">Features</a><a href="#settings-expiration">Expiration</a><a href="#settings-account">Account</a></nav>',2)])])),[[r]]),o("div",b5,[p(" Account usage shares the dashboard component. "),o("section",w5,[t[6]||(t[6]=o("div",{class:"paper-section-eyebrow"},"Quota",-1)),t[7]||(t[7]=o("p",{class:"paper-section-copy"},"What you are using across all your anonymizations. Conferences can lift these limits during a review window.",-1)),e.quota?(l(),Lt(n,{key:0,quota:e.quota,fmt:e.fmt},null,8,["quota","fmt"])):p("v-if",!0),e.quota?p("v-if",!0):(l(),d("div",k5,[(l(),d(X,null,re([1,2,3],(a,u)=>o("div",E5,[...t[5]||(t[5]=[o("div",{class:"quota-header"},[o("span",{class:"skeleton skeleton-line",style:{width:"40%"}}),o("span",{class:"skeleton skeleton-line",style:{width:"25%"}})],-1),o("div",{class:"quota-track"},null,-1)])])),64))]))]),N((l(),d("form",{class:"form needs-validation",name:"defaultsForm",novalidate:"",onSubmit:t[2]||(t[2]=me(a=>e.submitForm(a,()=>{e.saveDefault(a)}),["prevent"]))},[o("section",C5,[t[13]||(t[13]=o("div",{class:"paper-section-eyebrow"},"Terms to redact",-1)),t[14]||(t[14]=o("p",{class:"paper-section-copy"},"Pre-filled in every new anonymization. You can still edit the list per anonymization.",-1)),o("div",_5,[t[12]||(t[12]=o("label",{class:"paper-field-label",for:"terms"},"Default terms",-1)),N(o("textarea",S5,null,512),[[s,{state:e.viewState,set:a=>{e.terms=a},value:e.terms,form:"defaultsForm",options:{debounce:250}}]]),o("small",N5,[t[8]||(t[8]=b(" One term per line (regex allowed). Each match is replaced by ",-1)),o("code",null,c(e.site_options?.ANONYMIZATION_MASK||"XXX")+"-[N]",1),t[9]||(t[9]=b(", or use ",-1)),t[10]||(t[10]=o("code",null,"term=>replacement",-1)),t[11]||(t[11]=b(" to pick your own. ",-1))]),N(o("div",D5," Terms are in an invalid format ",512),[[Y,e.defaultsForm?.terms?.errors?.format]])])]),o("section",R5,[t[23]||(t[23]=o("div",{class:"paper-section-eyebrow"},"Display",-1)),t[24]||(t[24]=o("p",{class:"paper-section-copy"},"What reviewers can see inside an anonymized repository.",-1)),o("div",T5,[o("div",A5,[N(o("input",O5,null,512),[[s,{state:e.viewState,set:a=>{e.options.link=a},value:e.options?.link,form:"defaultsForm",options:{}}]]),t[15]||(t[15]=o("label",{class:"form-check-label",for:"link"},"Keep links",-1)),t[16]||(t[16]=o("small",{class:"form-text text-muted"},"Unchecked, every link in text files is removed.",-1))]),o("div",I5,[N(o("input",P5,null,512),[[s,{state:e.viewState,set:a=>{e.options.image=a},value:e.op
Line truncated
|
||||
`);for(let k=0;k<g.length;k++){let m=g[k];if(m.startsWith("diff --git")){h(),f();continue}if(m.startsWith("--- ")){i&&i.lines.length&&h(),f().oldPath=m.replace(/^--- (a\/)?/,"").trim();continue}if(m.startsWith("+++ ")){f().newPath=m.replace(/^\+\+\+ (b\/)?/,"").trim();continue}if(!(m.startsWith("index ")||m.startsWith("similarity index")||m.startsWith("rename ")||m.startsWith("new file mode")||m.startsWith("deleted file mode")||m.startsWith("Binary files"))){if(m.startsWith("@@")){let y=m.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);y&&(a=parseInt(y[1],10),u=parseInt(y[2],10)),f().lines.push({kind:"hunk",oldNo:"",newNo:"",text:m});continue}i&&(m.startsWith("+")?(i.lines.push({kind:"add",oldNo:"",newNo:u,text:m.slice(1)}),u++):m.startsWith("-")?(i.lines.push({kind:"remove",oldNo:a,newNo:"",text:m.slice(1)}),a++):(i.lines.push({kind:"ctx",oldNo:a,newNo:u,text:m.startsWith(" ")?m.slice(1):m}),a++,u++))}}return n(s,i),s.join("")}})({trustAsHtml:e=>e});function TM(e,t){return e==null?"":Number(e).toLocaleString(void 0,t==null?{}:{minimumFractionDigits:t,maximumFractionDigits:t})}function AM(e){return String(e??"").toUpperCase()}function OM(e,t,n=0){return(e||[]).slice(n,n+t)}function IM(e,t){return Array.isArray(e)?typeof t=="function"?e.filter(t):e:[]}function PM(e,t){if(!t)return e||[];let n=Array.isArray(t)?t:[t];return[...e||[]].sort((r,s)=>{for(let i of n){let a=i[0]==="-"?-1:1;i=i.replace(/^[+-]/,"");let u=i.split(".").reduce((h,g)=>h?.[g],r),f=i.split(".").reduce((h,g)=>h?.[g],s);if(typeof u=="string"&&(u=u.toLowerCase()),typeof f=="string"&&(f=f.toLowerCase()),u!==f)return(u==null?-1:f==null?1:u<f?-1:1)*a}return 0})}function VM(e,t){if(!e)return"";let n=new Date(e);if(isNaN(n))return String(e);if(!t||t==="mediumDate")return n.toLocaleDateString(void 0,{year:"numeric",month:"short",day:"numeric"});let r={yyyy:n.getFullYear(),MMM:n.toLocaleDateString(void 0,{month:"short"}),MM:String(n.getMonth()+1).padStart(2,"0"),dd:String(n.getDate()).padStart(2,"0"),d:n.getDate(),HH:String(n.getHours()).padStart(2,"0"),mm:String(n.getMinutes()).padStart(2,"0"),ss:String(n.getSeconds()).padStart(2,"0")};return t.replace(/yyyy|MMM|MM|dd|HH|mm|ss|d/g,s=>r[s])}function qM(e,t){return(t?.[e]??t?.[e===1?"one":"other"]??"").replace(/\{\}/g,e??0)}var Fl={ERRORS:{cache_busy:"Downloads are busy. Please try again shortly.",invalid_sort:"The selected sort order is invalid.",invalid_cursor:"This project page is no longer valid. Reload the list.",repository_changed:"The repository changed while processing. Please retry.",repository_job_cancelled:"The repository changed or was removed while processing.",storage_delete_failed:"Unable to remove cached files. Please try again later.",unknown_error:"Unknown error, contact the admin.",unreachable:"Anonymous GitHub is unreachable, contact the admin.",request_error:"Unable to download the file, check your connection or contact the admin.",not_found:"The requested resource could not be found.",not_connected:"You must be logged in to perform this action.",not_authorized:"You do not have permission to perform this action.",unable_to_connect_user:"Unable to connect your account. Please try again later.",user_not_found:"The requested user could not be found.",user_banned:"Your account has been banned. Contact the admin for more information.",repo_access_limited:"GitHub blocked access because the repository's organization restricts third-party OAuth apps. Ask an org owner to approve Anonymous GitHub under Settings \u2192 Third-party Access \u2192 OAuth app policy, or anonymize a personal fork instead.",repo_saml_enforcement:"The repository's organization enforces SAML single sign-on. Authorize your token for that organization (GitHub \u2192 Settings \u2192 Applications, or re-run the org's SSO sign-in), then retry. Alternatively, anonymize a personal fork.",repo_not_found:"The repository was not found on GitHub. Check the URL and spelling, make sure you are signed in to the account that can see it, and confirm the repo isn't hidden under an org that restricts third-party app access.",repo_empty:"The selected branch has no commits on GitHub. Push at least one commit, or pick a different branch, then retry.",repo_not_accessible:"Anonymous GitHub cannot access this repository. Verify the repository exists and that Anonymous GitHub has been authorized for the owning organization.",repository_archived:"This repository has been archived and its files are no longer available.",repository_expired:"The repository is expired.",invalid_status:"This action cannot be performed while the resource is in its current state.",repository_not_ready:"Anonymous GitHub is still processing the repository, it can take several minutes.",repository_not_accessible:"This repository is currently not accessible.",repo_is_updating:"Anonymous GitHub is still processing the repository, it can take several minutes.",invalid_repo:"The provided repository is not valid.",invalid_source_repository:"The repository source name
Line truncated
|
||||
jane.smith@example.org`},nq={class:"settings-syntax"},oq={class:"invalid-feedback"},sq={class:"settings-row"},iq={class:"settings-checks"},rq={class:"settings-check"},aq={class:"form-check-input",type:"checkbox",id:"link",name:"link"},lq={class:"settings-check"},dq={class:"form-check-input",type:"checkbox",id:"notebook",name:"notebook"},uq={class:"settings-check"},cq={class:"form-check-input",type:"checkbox",id:"image",name:"image"},pq={class:"settings-check"},fq={class:"form-check-input",type:"checkbox",id:"pdf",name:"pdf"},hq={class:"settings-check"},mq={class:"form-check-input",type:"checkbox",id:"loc",name:"loc"},vq={class:"settings-check"},gq={class:"form-check-input",type:"checkbox",id:"page",name:"page"},yq={class:"settings-row"},bq={class:"settings-check"},wq={class:"form-check-input",type:"checkbox",id:"update",name:"update"},kq={class:"settings-row"},Eq={class:"form-control",id:"expiration",name:"expiration"},Cq={class:"settings-help"},_q={class:"settings-save"},Sq={class:"settings-save-status draft-status",role:"status"},Nq={key:0},Dq={key:1},Rq={key:2},Tq=["disabled"],Aq={key:1,class:"settings-save-error",role:"alert"},Oq=["aria-busy"],Pq={key:0,role:"status"},Iq={key:1,class:"settings-notice",role:"alert"},Vq=["disabled"],qq={key:2,class:"settings-notice",role:"status"},Mq={key:3,class:"settings-notice",role:"status"},$q=["disabled"],Lq={class:"settings-row"},Uq={class:"settings-inline"},Fq={key:0,class:"settings-help"},Bq={key:0,class:"settings-help"},zq={id:"settings-quota"},Hq={key:1,role:"status"},jq={key:1,class:"settings-notice"},Gq={key:2,class:"settings-notice"},Wq={key:3,class:"settings-notice"},Kq={key:4,class:"settings-help"},Yq={key:5,class:"settings-plan billing-plan"},Qq={class:"settings-comparison"},Jq={class:"settings-cycles"},Xq=["checked","onChange"],Zq={class:"settings-help"},xq={class:"settings-actions"},e8=["disabled"],t8={key:6,class:"settings-help"},n8={class:"settings-row"},o8={key:0,class:"settings-inline"},s8=["disabled"],i8={key:1,class:"settings-help"},r8=["disabled"],a8={key:4,class:"settings-row"},l8={class:"settings-inline"},d8=["disabled"],u8={key:0,class:"settings-pass"},c8={class:"settings-help"},p8=["disabled"],f8={key:5,class:"settings-row"},h8={class:"settings-help"},m8={id:"settings-account",class:"settings-account-panel"},v8={class:"paper-account-card"},g8=["src"],y8={class:"paper-account-identity"},b8={class:"paper-account-name"},w8={class:"paper-danger-zone"},k8={key:0,class:"paper-inline-alert paper-inline-alert-error",role:"alert"},E8=["textContent"],C8=["disabled"],_8={key:1,class:"settings-delete-confirm"},S8={for:"delete-username"},N8={class:"settings-actions"},D8=["disabled"],R8=["disabled"];function ql(e,t){let s=We("quota-usage"),i=ye("field"),o=ye("form");return l(),d("div",Q5,[n("header",J5,[t[16]||(t[16]=n("h1",null,"Settings",-1)),n("div",X5,[n("span",null,"@"+u(e.user?.username),1)])]),n("nav",Z5,[(l(),d(W,null,Z([{id:"defaults",label:"Anonymization defaults"},{id:"billing",label:"Hosting & billing"},{id:"account",label:"Account"}],r=>n("button",{type:"button","aria-current":e.settingsTab===r.id?"page":null,onClick:a=>e.settingsTab=r.id},u(r.label),9,x5)),64))]),N(n("div",null,[t[37]||(t[37]=n("p",{class:"settings-scope"},"For new anonymizations only. Existing repositories keep their settings.",-1)),N((l(),d("form",{class:"form needs-validation",name:"defaultsForm",novalidate:"",onSubmit:t[1]||(t[1]=ue(r=>e.submitForm(r,()=>{e.saveDefault(r)}),["prevent"]))},[n("section",eq,[t[22]||(t[22]=n("div",null,[n("label",{class:"settings-label",for:"terms"},"Terms to redact"),n("p",{class:"settings-help"},"One term per line.")],-1)),n("div",null,[N(n("textarea",tq,null,512),[[i,{state:e.viewState,set:r=>{e.terms=r},value:e.terms,form:"defaultsForm",options:{debounce:250}}]]),n("details",nq,[t[21]||(t[21]=n("summary",null,"Regex & replacement syntax",-1)),n("p",null,[t[17]||(t[17]=y("Matches become ",-1)),n("code",null,u(e.site_options?.ANONYMIZATION_MASK||"XXX")+"-[N]",1),t[18]||(t[18]=y(". Use ",-1)),t[19]||(t[19]=n("code",null,"term=>replacement",-1)),t[20]||(t[20]=y(" for a custom replacement. Regex is supported.",-1))])]),N(n("div",oq,"Terms are in an invalid format.",512),[[G,e.defaultsForm?.terms?.errors?.format]])])]),n("section",sq,[t[31]||(t[31]=n("h2",null,"Reviewer display",-1)),n("div",null,[n("div",iq,[n("label",rq,[N(n("input",aq,null,512),[[i,{state:e.viewState,set:r=>{e.options.link=r},value:e.options?.link,form:"defaultsForm",options:{}}]]),t[23]||(t[23]=y("Keep links",-1))]),n("label",lq,[N(n("input",dq,null,512),[[i,{state:e.viewState,set:r=>{e.options.notebook=r},value:e.options?.notebook,form:"defaultsForm",options:{}}]]),t[24]||(t[24]=y("Render notebooks",-1))]),n("label",uq,[N(n("input",cq,null,512),[[i,{state:e.viewState,set:r=>{e.options.image=r},value:e.options?.image,form:"defaultsForm",options:{}}]]),t[25]||(t[25]=y("Show images",-1))]),n("label",pq,[N(n("input",fq,null,512),[[i,{state:e.viewState,set:r=>{e.options.pdf=r}
Line truncated
|
||||
`);for(let k=0;k<g.length;k++){let m=g[k];if(m.startsWith("diff --git")){v(),f();continue}if(m.startsWith("--- ")){r&&r.lines.length&&v(),f().oldPath=m.replace(/^--- (a\/)?/,"").trim();continue}if(m.startsWith("+++ ")){f().newPath=m.replace(/^\+\+\+ (b\/)?/,"").trim();continue}if(!(m.startsWith("index ")||m.startsWith("similarity index")||m.startsWith("rename ")||m.startsWith("new file mode")||m.startsWith("deleted file mode")||m.startsWith("Binary files"))){if(m.startsWith("@@")){let b=m.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);b&&(a=parseInt(b[1],10),c=parseInt(b[2],10)),f().lines.push({kind:"hunk",oldNo:"",newNo:"",text:m});continue}r&&(m.startsWith("+")?(r.lines.push({kind:"add",oldNo:"",newNo:c,text:m.slice(1)}),c++):m.startsWith("-")?(r.lines.push({kind:"remove",oldNo:a,newNo:"",text:m.slice(1)}),a++):(r.lines.push({kind:"ctx",oldNo:a,newNo:c,text:m.startsWith(" ")?m.slice(1):m}),a++,c++))}}return s(o,r),o.join("")}})({trustAsHtml:e=>e});function QM(e,t){return e==null?"":Number(e).toLocaleString(void 0,t==null?{}:{minimumFractionDigits:t,maximumFractionDigits:t})}function JM(e){return String(e??"").toUpperCase()}function XM(e,t,s=0){return(e||[]).slice(s,s+t)}function ZM(e,t){return Array.isArray(e)?typeof t=="function"?e.filter(t):e:[]}function xM(e,t){if(!t)return e||[];let s=Array.isArray(t)?t:[t];return[...e||[]].sort((i,o)=>{for(let r of s){let a=r[0]==="-"?-1:1;r=r.replace(/^[+-]/,"");let c=r.split(".").reduce((v,g)=>v?.[g],i),f=r.split(".").reduce((v,g)=>v?.[g],o);if(typeof c=="string"&&(c=c.toLowerCase()),typeof f=="string"&&(f=f.toLowerCase()),c!==f)return(c==null?-1:f==null?1:c<f?-1:1)*a}return 0})}function e6(e,t){if(!e)return"";let s=new Date(e);if(isNaN(s))return String(e);if(!t||t==="mediumDate")return s.toLocaleDateString(void 0,{year:"numeric",month:"short",day:"numeric"});let i={yyyy:s.getFullYear(),MMM:s.toLocaleDateString(void 0,{month:"short"}),MM:String(s.getMonth()+1).padStart(2,"0"),dd:String(s.getDate()).padStart(2,"0"),d:s.getDate(),HH:String(s.getHours()).padStart(2,"0"),mm:String(s.getMinutes()).padStart(2,"0"),ss:String(s.getSeconds()).padStart(2,"0")};return t.replace(/yyyy|MMM|MM|dd|HH|mm|ss|d/g,o=>i[o])}function t6(e,t){return(t?.[e]??t?.[e===1?"one":"other"]??"").replace(/\{\}/g,e??0)}var Hl={ERRORS:{cache_busy:"Downloads are busy. Please try again shortly.",invalid_sort:"The selected sort order is invalid.",invalid_cursor:"This project page is no longer valid. Reload the list.",repository_changed:"The repository changed while processing. Please retry.",repository_job_cancelled:"The repository changed or was removed while processing.",storage_delete_failed:"Unable to remove cached files. Please try again later.",unknown_error:"Unknown error, contact the admin.",unreachable:"Anonymous GitHub is unreachable, contact the admin.",request_error:"Unable to download the file, check your connection or contact the admin.",not_found:"The requested resource could not be found.",not_connected:"You must be logged in to perform this action.",not_authorized:"You do not have permission to perform this action.",unable_to_connect_user:"Unable to connect your account. Please try again later.",user_not_found:"The requested user could not be found.",user_banned:"Your account has been banned. Contact the admin for more information.",repo_access_limited:"GitHub blocked access because the repository's organization restricts third-party OAuth apps. Ask an org owner to approve Anonymous GitHub under Settings \u2192 Third-party Access \u2192 OAuth app policy, or anonymize a personal fork instead.",repo_saml_enforcement:"The repository's organization enforces SAML single sign-on. Authorize your token for that organization (GitHub \u2192 Settings \u2192 Applications, or re-run the org's SSO sign-in), then retry. Alternatively, anonymize a personal fork.",repo_not_found:"The repository was not found on GitHub. Check the URL and spelling, make sure you are signed in to the account that can see it, and confirm the repo isn't hidden under an org that restricts third-party app access.",repo_empty:"The selected branch has no commits on GitHub. Push at least one commit, or pick a different branch, then retry.",repo_not_accessible:"Anonymous GitHub cannot access this repository. Verify the repository exists and that Anonymous GitHub has been authorized for the owning organization.",repository_archived:"This repository has been archived and its files are no longer available.",repository_expired:"The repository is expired.",invalid_status:"This action cannot be performed while the resource is in its current state.",repository_not_ready:"Anonymous GitHub is still processing the repository, it can take several minutes.",repository_not_accessible:"This repository is currently not accessible.",repo_is_updating:"Anonymous GitHub is still processing the repository, it can take several minutes.",invalid_repo:"The provided repository is not valid.",invalid_source_repository:"The repository source name
Line truncated
|
||||
/*! Bundled license information:
|
||||
|
||||
@vue/shared/dist/shared.esm-bundler.js:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* global db, print */
|
||||
// Read-only: run with mongosh connected to the application database.
|
||||
// Only aggregate counts leave this process. No file-content collection is read.
|
||||
const now = new Date();
|
||||
const users = new Map(), github = new Map(), usernames = new Map();
|
||||
db.users.find({}, { _id: 1, externalIDs: 1, username: 1 }).maxTimeMS(60000).forEach(u => {
|
||||
const id = String(u._id);
|
||||
users.set(id, true);
|
||||
if (u.externalIDs?.github) github.set(String(u.externalIDs.github), id);
|
||||
if (u.username) usernames.set(u.username, id);
|
||||
});
|
||||
const states = {};
|
||||
const active = new Map(), reserved = new Map();
|
||||
function add(map, r) {
|
||||
const id = String(r.owner);
|
||||
if (!map.has(id)) map.set(id, { count: 0, bytes: 0, measured: 0, collaborators: new Set() });
|
||||
const a = map.get(id);
|
||||
a.count++;
|
||||
a.bytes += r.size?.storage || 0;
|
||||
if (r.sizeComputedAt) a.measured++;
|
||||
for (const c of r.coauthors || []) {
|
||||
const user = c.githubId ? github.get(String(c.githubId)) : usernames.get(c.username);
|
||||
if (user && user !== id) a.collaborators.add(user);
|
||||
}
|
||||
}
|
||||
let shared = 0;
|
||||
db.anonymizedrepositories.find({}, { owner: 1, status: 1, options: 1, size: 1, sizeComputedAt: 1, coauthors: 1 }).maxTimeMS(60000).forEach(r => {
|
||||
states[r.status] = (states[r.status] || 0) + 1;
|
||||
if (!["removed", "expired"].includes(r.status)) add(reserved, r);
|
||||
if (r.status === "ready" && (r.options?.expirationMode === "never" || !r.options?.expirationDate || r.options.expirationDate > now)) {
|
||||
add(active, r);
|
||||
if (r.coauthors?.length) shared++;
|
||||
}
|
||||
});
|
||||
function summarize(map) {
|
||||
const entries = [...map.values()], counts = entries.map(x => x.count).sort((a,b) => a-b);
|
||||
const percentile = p => counts[Math.max(0, Math.ceil(counts.length*p)-1)] || 0;
|
||||
return {
|
||||
owners: map.size, registeredOwners: [...map.keys()].filter(id => users.has(id)).length,
|
||||
repositories: counts.reduce((a,b) => a+b, 0),
|
||||
withSizeComputedAt: entries.reduce((a,b) => a+b.measured, 0),
|
||||
buckets: [1,2,3,5,10,20,Infinity].map((max,i,ends) => ({max: Number.isFinite(max) ? max : "above20", owners: counts.filter(n => n > (ends[i-1] || 0) && n <= max).length})),
|
||||
percentiles: {p50:percentile(.5),p90:percentile(.9),p95:percentile(.95),p99:percentile(.99),max:counts.at(-1)},
|
||||
countLimits: [3,5,10,20].map(limit => ({limit, atOrAbove:entries.filter(x=>x.count>=limit).length, above:entries.filter(x=>x.count>limit).length,
|
||||
coauthorsOfOwnersAbove:new Set(entries.filter(x=>x.count>limit).flatMap(x=>[...x.collaborators])).size})),
|
||||
storageLimits: [.5,1,2,5].map(gib => ({gib, above:entries.filter(x=>x.bytes>gib*1024**3).length})),
|
||||
aboveTenOrTwoGiB: entries.filter(x=>x.count>10 || x.bytes>2*1024**3).length,
|
||||
coauthorsOfOwnersAboveTenOrTwoGiB: new Set(entries.filter(x=>x.count>10 || x.bytes>2*1024**3).flatMap(x=>[...x.collaborators])).size,
|
||||
coauthorsOfOwnersAtTen: new Set(entries.filter(x=>x.count>=10).flatMap(x=>[...x.collaborators])).size,
|
||||
distinctCoauthors:new Set(entries.flatMap(x=>[...x.collaborators])).size
|
||||
};
|
||||
}
|
||||
print(JSON.stringify({asOf:now, registeredAccounts:users.size, statuses:states, activeSharedRepositories:shared, active:summarize(active), initialReservations:summarize(reserved)}, null, 2));
|
||||
@@ -0,0 +1,190 @@
|
||||
const { setTimeout } = require("node:timers");
|
||||
// Explicit opt-in integration test. Creates only tagged sandbox objects.
|
||||
// Credentials live outside the repository; never log Stripe errors verbatim.
|
||||
const fs = require("node:fs");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
require("ts-node/register/transpile-only");
|
||||
const mongoose = require("mongoose");
|
||||
const Stripe = require("stripe");
|
||||
const config = require("../src/config").default;
|
||||
const { BillingAccount, BillingGrant, BillingQuota, BillingLock } = require("../src/core/billing/models");
|
||||
const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
|
||||
const Conference = require("../src/core/model/conference/conferences.model").default;
|
||||
const { createCheckout, syncSubscription, fulfillCheckout, cancelAccountBilling } = require("../src/core/billing/stripe");
|
||||
const { allowance } = require("../src/core/billing/quotas");
|
||||
const { neverExpirationDeadline, reconcileNeverExpiration } = require("../src/core/billing/expiration");
|
||||
const run = crypto.randomUUID();
|
||||
const metadata = { application: "anonymous-github", sandbox_test_run: run };
|
||||
const results = [];
|
||||
const customers = [], prices = [], products = [], clocks = [];
|
||||
let stripe;
|
||||
async function check(name, fn) { await fn(); results.push(name); console.log("PASS " + name); }
|
||||
async function customer() {
|
||||
const owner = new mongoose.Types.ObjectId().toString();
|
||||
const c = await stripe.customers.create({ metadata: { ...metadata, owner }, payment_method: "pm_card_visa", invoice_settings: { default_payment_method: "pm_card_visa" } });
|
||||
customers.push(c.id);
|
||||
await BillingAccount.create({ _id: owner, customerId: c.id });
|
||||
return { owner, id: c.id };
|
||||
}
|
||||
async function main() {
|
||||
const credentialsPath = process.env.STRIPE_SANDBOX_CREDENTIALS;
|
||||
if (!credentialsPath) throw new Error("Set STRIPE_SANDBOX_CREDENTIALS to a private JSON file");
|
||||
const { secret } = JSON.parse(fs.readFileSync(credentialsPath, "utf8"));
|
||||
if (typeof secret !== "string" || !secret.startsWith("sk_test_")) throw new Error("Sandbox secret key required");
|
||||
stripe = new Stripe(secret, { maxNetworkRetries: 2, timeout: 20000 });
|
||||
assert.equal((await stripe.balance.retrieve()).livemode, false);
|
||||
const uri = process.env.BILLING_TEST_MONGODB_URI;
|
||||
assert.match(new URL(uri).pathname, /^\/billing_test_/);
|
||||
await mongoose.connect(uri);
|
||||
await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, Repo, Conference].map(m => m.init()));
|
||||
Object.assign(config, { BILLING_ENABLED: true, STRIPE_SECRET_KEY: secret,
|
||||
STRIPE_WEBHOOK_SECRET: crypto.randomBytes(32).toString("hex"), BILLING_ORIGIN: "http://localhost:5183", BILLING_AUTOMATIC_TAX: false });
|
||||
const product = await stripe.products.create({ name: "Anonymous GitHub sandbox integration " + run, metadata });
|
||||
products.push(product.id);
|
||||
for (const [key, amount, interval] of [
|
||||
["STRIPE_AUTHOR_MONTHLY_PRICE",500,"month"],["STRIPE_AUTHOR_ANNUAL_PRICE",4900,"year"],
|
||||
["STRIPE_AUTHOR_PASS_PRICE",1500],["STRIPE_CONFERENCE_PRICE",19900]]) {
|
||||
const p = await stripe.prices.create({ product: product.id, currency: "eur", unit_amount: amount, metadata, ...(interval ? { recurring: { interval } } : {}) });
|
||||
prices.push(p.id); config[key] = p.id;
|
||||
}
|
||||
const buyer = await customer();
|
||||
await Repo.create({ repoId: "sandbox-"+run, owner: buyer.owner, status: "ready" });
|
||||
await Conference.create({ conferenceID: "sandbox-"+run, owners: [buyer.owner], status: "pending_payment",
|
||||
startDate: new Date(), endDate: new Date(Date.now()+86400000*30), plan: { planID: "conference" } });
|
||||
for (const plan of ["author_monthly","author_annual","author_pass","conference"]) {
|
||||
await check(plan + " Checkout creation, reuse, unpaid denial and expiry", async () => {
|
||||
const target = ["author_pass","conference"].includes(plan) ? "sandbox-"+run : "";
|
||||
const url = await createCheckout(buyer.owner, plan, target, stripe);
|
||||
assert.match(url, /^https:\/\/checkout.stripe.com\//);
|
||||
assert.equal(await createCheckout(buyer.owner,plan,target,stripe), url);
|
||||
const account = await BillingAccount.findById(buyer.owner);
|
||||
await fulfillCheckout(account.checkout.id, stripe);
|
||||
assert.equal(await BillingGrant.countDocuments({ owner: buyer.owner }),0);
|
||||
await stripe.checkout.sessions.expire(account.checkout.id);
|
||||
});
|
||||
}
|
||||
for (const [plan,key] of [["author_monthly","STRIPE_AUTHOR_MONTHLY_PRICE"],["author_annual","STRIPE_AUTHOR_ANNUAL_PRICE"]]) {
|
||||
const c = await customer();
|
||||
const sub = await stripe.subscriptions.create({ customer:c.id, items:[{price:config[key]}], metadata:{...metadata,owner:c.owner} });
|
||||
await check(plan + " successful payment and entitlement",async()=>{
|
||||
await syncSubscription(sub.id,stripe);
|
||||
assert.equal((await allowance(c.owner)).plan,plan);
|
||||
assert.ok(await neverExpirationDeadline(c.owner));
|
||||
});
|
||||
await check(plan + " duplicate subscription prevention",async()=>{
|
||||
await assert.rejects(createCheckout(c.owner,plan,"",stripe),/billing_subscription_exists/);
|
||||
});
|
||||
await check(plan + " cancellation preserves paid period",async()=>{
|
||||
await stripe.subscriptions.update(sub.id,{cancel_at_period_end:true});
|
||||
await syncSubscription(sub.id,stripe);
|
||||
assert.equal((await allowance(c.owner)).plan,plan);
|
||||
assert.equal((await BillingGrant.findById(sub.id)).cancelAtPeriodEnd,true);
|
||||
});
|
||||
await check(plan + " immediate cancellation and grace expiry",async()=>{
|
||||
const deadline = await neverExpirationDeadline(c.owner);
|
||||
await Repo.create({repoId:plan+"-"+run,owner:c.owner,status:"ready",options:{expirationMode:"never"},billingNeverExpiresUntil:deadline});
|
||||
await stripe.subscriptions.cancel(sub.id);
|
||||
await syncSubscription(sub.id,stripe);
|
||||
assert.equal((await allowance(c.owner)).plan,"free");
|
||||
await reconcileNeverExpiration(new Date(deadline.getTime()-1));
|
||||
assert.equal((await Repo.findOne({owner:c.owner})).options.expirationMode,"never");
|
||||
await reconcileNeverExpiration(deadline);
|
||||
assert.equal((await Repo.findOne({owner:c.owner})).options.expirationMode,"remove");
|
||||
});
|
||||
}
|
||||
await check("declined initial payment grants no paid access",async()=>{
|
||||
const c=await customer();
|
||||
const pm=await stripe.paymentMethods.attach("pm_card_chargeCustomerFail",{customer:c.id});
|
||||
await stripe.customers.update(c.id,{invoice_settings:{default_payment_method:pm.id}});
|
||||
const sub=await stripe.subscriptions.create({customer:c.id,items:[{price:config.STRIPE_AUTHOR_MONTHLY_PRICE}],payment_behavior:"default_incomplete",metadata:{...metadata,owner:c.owner}});
|
||||
await syncSubscription(sub.id,stripe);
|
||||
assert.equal((await allowance(c.owner)).plan,"free");
|
||||
await assert.rejects(neverExpirationDeadline(c.owner),/subscription_required/);
|
||||
});
|
||||
|
||||
await check("subscription refund revocation, signed event replay and tamper rejection",async()=>{
|
||||
const c=await customer();
|
||||
const sub=await stripe.subscriptions.create({customer:c.id,items:[{price:config.STRIPE_AUTHOR_MONTHLY_PRICE}],metadata:{...metadata,owner:c.owner}});
|
||||
await syncSubscription(sub.id,stripe);
|
||||
const payments=await stripe.invoicePayments.list({invoice:sub.latest_invoice});
|
||||
const pi=payments.data.find(p=>p.payment.type==="payment_intent").payment.payment_intent;
|
||||
const refund=await stripe.refunds.create({payment_intent:typeof pi==="string"?pi:pi.id});
|
||||
const charge=await stripe.charges.retrieve(refund.charge);
|
||||
const express=require("express");
|
||||
const app=express();
|
||||
app.use("/webhook",require("../src/server/routes/billing").billingWebhook);
|
||||
const server=await new Promise(resolve=>{const srv=app.listen(0,"127.0.0.1",()=>resolve(srv));});
|
||||
try {
|
||||
// Real Stripe charge payload; local signature exercises the production HTTP route.
|
||||
const payload=JSON.stringify({id:"evt_sandbox_"+run,type:"charge.refunded",data:{object:charge}});
|
||||
const signature=stripe.webhooks.generateTestHeaderString({payload,secret:config.STRIPE_WEBHOOK_SECRET});
|
||||
const post=body=>globalThis.fetch("http://127.0.0.1:"+server.address().port+"/webhook",{method:"POST",headers:{"content-type":"application/json","stripe-signature":signature},body});
|
||||
assert.equal((await post(payload)).status,200);
|
||||
assert.equal((await post(payload)).status,200);
|
||||
assert.equal((await post(payload+" ")).status,400);
|
||||
await require("../src/core/billing/events").processBillingInbox();
|
||||
assert.equal((await allowance(c.owner)).plan,"free");
|
||||
await syncSubscription(sub.id,stripe);
|
||||
assert.equal((await allowance(c.owner)).plan,"free");
|
||||
} finally { server.closeAllConnections();await new Promise(resolve=>server.close(resolve)); }
|
||||
});
|
||||
await check("subscription renewal, failed renewal, and recovery using a Stripe test clock",async()=>{
|
||||
const clock=await stripe.testHelpers.testClocks.create({frozen_time:Math.floor(Date.now()/1000),name:"Anonymous GitHub "+run});
|
||||
clocks.push(clock.id);
|
||||
const owner=new mongoose.Types.ObjectId().toString();
|
||||
const c=await stripe.customers.create({test_clock:clock.id,payment_method:"pm_card_visa",invoice_settings:{default_payment_method:"pm_card_visa"},metadata:{...metadata,owner}});
|
||||
customers.push(c.id);await BillingAccount.create({_id:owner,customerId:c.id});
|
||||
let sub=await stripe.subscriptions.create({customer:c.id,items:[{price:config.STRIPE_AUTHOR_MONTHLY_PRICE}],metadata:{...metadata,owner}});
|
||||
await syncSubscription(sub.id,stripe);
|
||||
const original=(await BillingGrant.findById(sub.id)).until.getTime();
|
||||
async function advance(time) {
|
||||
await stripe.testHelpers.testClocks.advance(clock.id,{frozen_time:time});
|
||||
for(let i=0;i<60;i++) {
|
||||
if((await stripe.testHelpers.testClocks.retrieve(clock.id)).status==="ready") return;
|
||||
await new Promise(r=>setTimeout(r,1000));
|
||||
}
|
||||
throw new Error("Test clock did not settle");
|
||||
}
|
||||
await advance(sub.items.data[0].current_period_end+1);
|
||||
sub=await stripe.subscriptions.retrieve(sub.id);
|
||||
let invoice=await stripe.invoices.retrieve(sub.latest_invoice);
|
||||
if(invoice.status==="draft") invoice=await stripe.invoices.finalizeInvoice(invoice.id);
|
||||
if(invoice.status==="open") await stripe.invoices.pay(invoice.id);
|
||||
await syncSubscription(sub.id,stripe);
|
||||
assert.ok((await BillingGrant.findById(sub.id)).until.getTime()>original);
|
||||
const paidThrough=(await BillingGrant.findById(sub.id)).until.getTime();
|
||||
const failed=await stripe.paymentMethods.attach("pm_card_chargeCustomerFail",{customer:c.id});
|
||||
await stripe.customers.update(c.id,{invoice_settings:{default_payment_method:failed.id}});
|
||||
await advance(sub.items.data[0].current_period_end+1);
|
||||
sub=await stripe.subscriptions.retrieve(sub.id);
|
||||
invoice=await stripe.invoices.retrieve(sub.latest_invoice);
|
||||
if(invoice.status==="draft") invoice=await stripe.invoices.finalizeInvoice(invoice.id);
|
||||
try { await stripe.invoices.pay(invoice.id); } catch(e) { assert.equal(e.type,"StripeCardError"); }
|
||||
await syncSubscription(sub.id,stripe);
|
||||
assert.equal((await BillingGrant.findById(sub.id)).until.getTime(),paidThrough);
|
||||
// App wall time does not follow Stripe's test clock. Verify paid-through does not advance.
|
||||
const good=await stripe.paymentMethods.attach("pm_card_visa",{customer:c.id});
|
||||
await stripe.customers.update(c.id,{invoice_settings:{default_payment_method:good.id}});
|
||||
await stripe.invoices.pay(invoice.id);
|
||||
await syncSubscription(sub.id,stripe);
|
||||
assert.ok((await BillingGrant.findById(sub.id)).until.getTime()>paidThrough);
|
||||
});
|
||||
await check("account deletion cancels subscription",async()=>{
|
||||
const c=await customer();
|
||||
const sub=await stripe.subscriptions.create({customer:c.id,items:[{price:config.STRIPE_AUTHOR_MONTHLY_PRICE}],metadata:{...metadata,owner:c.owner}});
|
||||
await syncSubscription(sub.id,stripe);
|
||||
await cancelAccountBilling(c.owner);
|
||||
assert.equal((await stripe.subscriptions.retrieve(sub.id)).status,"canceled");
|
||||
});
|
||||
}
|
||||
main().catch(e=>{console.error(JSON.stringify({failure:true,type:e.type||e.name,code:e.code,parameter:e.param,message:e.type?undefined:e.message}));process.exitCode=1;}).finally(async()=>{
|
||||
if(stripe) {
|
||||
for(const id of customers) try { await stripe.customers.del(id); } catch { console.error("Customer cleanup failed: "+id); }
|
||||
for(const id of clocks) try { await stripe.testHelpers.testClocks.del(id); } catch { console.error("Clock cleanup failed: "+id); }
|
||||
for(const id of prices) try { await stripe.prices.update(id,{active:false}); } catch { console.error("Price cleanup failed: "+id); }
|
||||
for(const id of products) try { await stripe.products.update(id,{active:false}); } catch { console.error("Product cleanup failed: "+id); }
|
||||
}
|
||||
if(mongoose.connection.readyState) await mongoose.disconnect();
|
||||
console.log(JSON.stringify({run,passed:results.length,results}));
|
||||
});
|
||||
@@ -2,6 +2,24 @@ import { resolve } from "path";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
interface Config {
|
||||
BILLING_ENABLED: boolean;
|
||||
BILLING_CHECKOUT_ENABLED: boolean;
|
||||
BILLING_ORIGIN: string;
|
||||
BILLING_AUTOMATIC_TAX: boolean;
|
||||
STRIPE_SECRET_KEY: string;
|
||||
STRIPE_WEBHOOK_SECRET: string;
|
||||
STRIPE_AUTHOR_MONTHLY_PRICE: string;
|
||||
STRIPE_AUTHOR_ANNUAL_PRICE: string;
|
||||
STRIPE_AUTHOR_PASS_PRICE: string;
|
||||
STRIPE_CONFERENCE_PRICE: string;
|
||||
FREE_AUTHOR_REPOSITORIES: number;
|
||||
FREE_AUTHOR_STORAGE: number;
|
||||
PAID_AUTHOR_REPOSITORIES: number;
|
||||
PAID_AUTHOR_STORAGE: number;
|
||||
PAID_PASS_STORAGE: number;
|
||||
PAID_CONFERENCE_REPOSITORIES: number;
|
||||
PAID_CONFERENCE_STORAGE: number;
|
||||
|
||||
GITHUB_APP_ENABLED: boolean;
|
||||
GITHUB_APP_NEW_CONNECTIONS: boolean;
|
||||
GITHUB_OAUTH_ENABLED: boolean;
|
||||
@@ -67,6 +85,24 @@ interface Config {
|
||||
RATE_LIMIT: number;
|
||||
}
|
||||
const config: Config = {
|
||||
BILLING_ENABLED: false,
|
||||
BILLING_CHECKOUT_ENABLED: true,
|
||||
BILLING_ORIGIN: "http://localhost:5000",
|
||||
BILLING_AUTOMATIC_TAX: false,
|
||||
STRIPE_SECRET_KEY: "",
|
||||
STRIPE_WEBHOOK_SECRET: "",
|
||||
STRIPE_AUTHOR_MONTHLY_PRICE: "",
|
||||
STRIPE_AUTHOR_ANNUAL_PRICE: "",
|
||||
STRIPE_AUTHOR_PASS_PRICE: "",
|
||||
STRIPE_CONFERENCE_PRICE: "",
|
||||
FREE_AUTHOR_REPOSITORIES: 5,
|
||||
FREE_AUTHOR_STORAGE: 2 * 1024 ** 3,
|
||||
PAID_AUTHOR_REPOSITORIES: 20,
|
||||
PAID_AUTHOR_STORAGE: 10 * 1024 ** 3,
|
||||
PAID_PASS_STORAGE: 2 * 1024 ** 3,
|
||||
PAID_CONFERENCE_REPOSITORIES: 100,
|
||||
PAID_CONFERENCE_STORAGE: 50 * 1024 ** 3,
|
||||
|
||||
GITHUB_APP_ENABLED: false,
|
||||
GITHUB_APP_NEW_CONNECTIONS: true,
|
||||
GITHUB_OAUTH_ENABLED: true,
|
||||
@@ -199,3 +235,10 @@ if (config.GITHUB_APP_ENABLED) {
|
||||
if (!config.GITHUB_APP_PRIVATE_KEY && !config.GITHUB_APP_PRIVATE_KEY_FILE) throw new Error("A GitHub App private key is required");
|
||||
}
|
||||
export default config;
|
||||
|
||||
if (config.BILLING_ENABLED) {
|
||||
for (const key of ["FREE_AUTHOR_REPOSITORIES", "FREE_AUTHOR_STORAGE", "PAID_AUTHOR_REPOSITORIES", "PAID_AUTHOR_STORAGE", "PAID_PASS_STORAGE", "PAID_CONFERENCE_REPOSITORIES", "PAID_CONFERENCE_STORAGE"] as const) {
|
||||
if (!Number.isSafeInteger(config[key]) || config[key] <= 0) throw new Error(`${key} must be a positive integer`);
|
||||
}
|
||||
if (!config.STRIPE_SECRET_KEY || !config.STRIPE_WEBHOOK_SECRET) throw new Error("Billing requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET");
|
||||
}
|
||||
@@ -265,6 +265,7 @@ export default class AnonymizedFile {
|
||||
folder === "" || fileDir === folder || fileDir.startsWith(folder + "/")
|
||||
);
|
||||
if (!isAffected) return null;
|
||||
if (this.repository.model.billingManaged) throw new AnonymousError("repository_tree_incomplete", { httpStatus: 409 });
|
||||
const source = this.repository.source as {
|
||||
fetchFileInfoFromPath?: (filePath: string) => Promise<IFile | null>;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { allowance, reserveRepository } from "./billing/quotas";
|
||||
import AnonymizedPathModel from "./model/anonymized-path";
|
||||
import storage from "./storage";
|
||||
import { contentGenerationPrefix, contentRetirementMarker } from "./content-generation";
|
||||
@@ -224,6 +225,7 @@ export default class Repository {
|
||||
const streamed = this.model.source.type !== "Zip";
|
||||
const source = this.source;
|
||||
const files = await source.getFiles(opt.progress);
|
||||
if (this.model.billingManaged) await reserveRepository(String(this.model.owner), this.repoId, files.reduce((sum, file) => sum + (file.size || 0), 0), this.model.conference);
|
||||
const sourceWithTruncation = source as unknown as { truncatedFolderList?: string[] };
|
||||
const truncatedFolders = Array.isArray(sourceWithTruncation.truncatedFolderList)
|
||||
? [...sourceWithTruncation.truncatedFolderList] : [];
|
||||
@@ -638,6 +640,10 @@ export default class Repository {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Reviewer visits must not trigger an unaffordable refresh after paid access ends.
|
||||
// Free managed repositories can still be refreshed explicitly by their owner.
|
||||
if (config.BILLING_ENABLED && this.model.billingManaged && !opt?.force &&
|
||||
(await allowance(String(this.model.owner), this.repoId, this.model.conference)).plan === "free") return;
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import config from "../../config";
|
||||
import AnonymousError from "../AnonymousError";
|
||||
|
||||
export type PlanId = "author_monthly" | "author_annual" | "author_pass" | "conference";
|
||||
export const GiB = 1024 ** 3;
|
||||
export function billingPlans() {
|
||||
return [
|
||||
{ id: "author_monthly" as PlanId, name: "Author monthly", priceId: config.STRIPE_AUTHOR_MONTHLY_PRICE, recurring: true, repositories: config.PAID_AUTHOR_REPOSITORIES, storage: config.PAID_AUTHOR_STORAGE, description: "Never-expiring repositories while subscribed, then a 90-day grace period. Renews monthly. Cancel renewal at any time." },
|
||||
{ id: "author_annual" as PlanId, name: "Author annual", priceId: config.STRIPE_AUTHOR_ANNUAL_PRICE, recurring: true, repositories: config.PAID_AUTHOR_REPOSITORIES, storage: config.PAID_AUTHOR_STORAGE, description: "Never-expiring repositories while subscribed, then a 90-day grace period. Renews annually. Cancel renewal at any time." },
|
||||
{ id: "author_pass" as PlanId, name: "Author review pass", priceId: config.STRIPE_AUTHOR_PASS_PRICE, recurring: false, repositories: 1, storage: config.PAID_PASS_STORAGE, description: "One repository for 180 days. No automatic renewal." },
|
||||
{ id: "conference" as PlanId, name: "Conference review package", priceId: config.STRIPE_CONFERENCE_PRICE, recurring: false, repositories: config.PAID_CONFERENCE_REPOSITORIES, storage: config.PAID_CONFERENCE_STORAGE, description: "A review window of up to 180 days. No automatic renewal." },
|
||||
];
|
||||
}
|
||||
export function billingError(code: string, status = 400) { return new AnonymousError(code, { httpStatus: status }); }
|
||||
export function billingOrigin() {
|
||||
const url = new URL(config.BILLING_ORIGIN);
|
||||
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
|
||||
(url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)))) throw new Error("Invalid BILLING_ORIGIN");
|
||||
return url.origin;
|
||||
}
|
||||
export function requireBilling() {
|
||||
if (!config.BILLING_ENABLED || !config.STRIPE_SECRET_KEY || !config.STRIPE_WEBHOOK_SECRET) throw billingError("billing_unavailable", 503);
|
||||
billingOrigin();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { BillingDraft } from "./models";
|
||||
import { billingError } from "./config";
|
||||
|
||||
const fields = ["sourceUrl", "terms", "repoId", "source", "options", "conference", "githubConnection"];
|
||||
export function validDraftPath(path: unknown): path is string {
|
||||
return typeof path === "string" && /^\/anonymize(?:\/[A-Za-z0-9_-]{1,200})?$/.test(path);
|
||||
}
|
||||
export async function saveBillingDraft(owner: string, path: unknown, values: unknown) {
|
||||
if (!validDraftPath(path) || !values || typeof values !== "object" || Array.isArray(values) ||
|
||||
Buffer.byteLength(JSON.stringify(values)) > 64 * 1024) throw billingError("billing_invalid_draft");
|
||||
const selected = Object.fromEntries(fields.filter(key => Object.prototype.hasOwnProperty.call(values, key))
|
||||
.map(key => [key, (values as Record<string, unknown>)[key]]));
|
||||
// A draft is private user input, never an authorization or an entitlement.
|
||||
return BillingDraft.create({ _id: randomUUID(), owner, path, values: selected,
|
||||
expiresAt: new Date(Date.now() + 7 * 86400_000) });
|
||||
}
|
||||
export async function getBillingDraft(owner: string, id: unknown) {
|
||||
if (typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id)) throw billingError("billing_draft_not_found", 404);
|
||||
const draft = await BillingDraft.findOne({ _id: id, owner, expiresAt: { $gt: new Date() } }).lean();
|
||||
if (!draft) throw billingError("billing_draft_not_found", 404);
|
||||
return draft;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Stripe = require("stripe");
|
||||
import config from "../../config";
|
||||
import { randomUUID } from "crypto";
|
||||
import { BillingEvent, BillingAccount } from "./models";
|
||||
import { processBillingEvent } from "./stripe";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const logger = createLogger("billing");
|
||||
export function billingFailure(error: unknown) {
|
||||
const e = error as { type?: string; code?: string; requestId?: string };
|
||||
// Do not persist provider messages, request bodies, email addresses or keys.
|
||||
return [e?.type, e?.code, e?.requestId].filter(v => typeof v === "string" && /^[a-zA-Z0-9_]+$/.test(v)).join(":") || "billing_processing_failed";
|
||||
}
|
||||
export async function receiveBillingEvent(event: Stripe.Event) {
|
||||
const value = event.data.object as unknown as Record<string, unknown>;
|
||||
// Retain only routing references, never the full Stripe customer/payment payload.
|
||||
const parent = value.parent as { subscription_details?: { subscription?: unknown } } | undefined;
|
||||
const ref = (v: unknown) => typeof v === "string" ? v : (v as { id?: string } | null)?.id;
|
||||
const object = { id: value.id, parent: { subscription_details: { subscription: ref(parent?.subscription_details?.subscription) } }, payment_intent: ref(value.payment_intent),
|
||||
charge: ref(value.charge), amount: value.amount, amount_refunded: value.amount_refunded, refunded: value.refunded, disputed: value.disputed };
|
||||
const id = `${event.livemode ? "live" : "test"}:${event.account || "platform"}:${event.id}`;
|
||||
await BillingEvent.updateOne({ _id: id }, { $setOnInsert: { eventId: event.id, type: event.type, object } }, { upsert: true });
|
||||
}
|
||||
export async function processBillingInbox(limit = 25, processEvent = processBillingEvent, now = new Date()) {
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const token = randomUUID();
|
||||
const namespace = /^(sk|rk)_live_/.test(config.STRIPE_SECRET_KEY) ? /^live:platform:/ : /^test:platform:/;
|
||||
const event = await BillingEvent.findOneAndUpdate({ _id: namespace, $or: [
|
||||
{ state: "pending", nextAttemptAt: { $lte: now } },
|
||||
{ state: "processing", leaseUntil: { $lte: now } },
|
||||
] }, { $set: { state: "processing", token, leaseUntil: new Date(now.getTime() + 300_000) }, $inc: { attempts: 1 } },
|
||||
{ new: true, sort: { receivedAt: 1 } });
|
||||
if (!event) break;
|
||||
try {
|
||||
await processEvent({ id: event.eventId, type: event.type, data: { object: event.object } } as Stripe.Event);
|
||||
await BillingEvent.updateOne({ _id: event._id, token }, { $set: { state: "complete", completedAt: new Date() }, $unset: { token: 1, leaseUntil: 1, lastError: 1 } });
|
||||
} catch (error) {
|
||||
const dead = event.attempts >= 12;
|
||||
const lastError = billingFailure(error);
|
||||
await BillingEvent.updateOne({ _id: event._id, token }, { $set: { state: dead ? "failed" : "pending", lastError,
|
||||
nextAttemptAt: new Date(now.getTime() + Math.min(3600_000, 1000 * 2 ** event.attempts)) }, $unset: { token: 1, leaseUntil: 1 } });
|
||||
logger.error(dead ? "billing event requires replay" : "billing event retry scheduled", { eventId: event.eventId, type: event.type, attempts: event.attempts, code: lastError });
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function billingHealth() {
|
||||
const [pending, failed, oldest, unresolved, reconciliationFailed] = await Promise.all([
|
||||
BillingEvent.countDocuments({ state: { $in: ["pending", "processing"] } }),
|
||||
BillingEvent.countDocuments({ state: "failed" }),
|
||||
BillingEvent.findOne({ state: { $in: ["pending", "processing"] } }).sort({ receivedAt: 1 }).select("receivedAt").lean(),
|
||||
BillingAccount.countDocuments({ "checkout.key": { $exists: true }, "checkout.id": { $exists: false } }),
|
||||
BillingAccount.countDocuments({ reconcileError: { $exists: true } }),
|
||||
]);
|
||||
return { pending, failed, oldestPendingSeconds: oldest?.receivedAt ? Math.floor((Date.now() - oldest.receivedAt.getTime()) / 1000) : 0, unresolved, reconciliationFailed };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { reconcileBillingAccount } from "./reconciliation";
|
||||
import { createLogger } from "../logger";
|
||||
import config from "../../config";
|
||||
import { BillingGrant } from "./models";
|
||||
import { billingError } from "./config";
|
||||
import { usableGrant } from "./quotas";
|
||||
import Repo from "../model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
|
||||
export const NEVER_EXPIRATION_GRACE_MS = 90 * 86400000;
|
||||
async function authorGrants(owner: string) {
|
||||
return BillingGrant.find({ scope: `user:${owner}`, planId: { $in: ["author_monthly", "author_annual"] } }).lean();
|
||||
}
|
||||
|
||||
// This is an author benefit, independent of which scope pays the storage quota.
|
||||
export async function neverExpirationDeadline(owner: string, existing?: Date) {
|
||||
if (!config.BILLING_ENABLED) return undefined;
|
||||
const grants = await authorGrants(owner);
|
||||
const active = grants.filter(g => usableGrant(g));
|
||||
if (!active.length) {
|
||||
if (existing && existing > new Date()) return existing;
|
||||
throw billingError("subscription_required_for_never_expiration", 402);
|
||||
}
|
||||
return new Date(Math.max(existing?.getTime() || 0, ...active.map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS)));
|
||||
}
|
||||
|
||||
// Run before ordinary expiration maintenance. Legacy never-expiring links have
|
||||
// no billing deadline and are deliberately excluded.
|
||||
export async function reconcileNeverExpiration(now = new Date(), reconcile = reconcileBillingAccount) {
|
||||
if (!config.BILLING_ENABLED) return;
|
||||
const cursor = Repo.find({ "options.expirationMode": "never", billingNeverExpiresUntil: { $exists: true },
|
||||
status: { $nin: ["removed", "expired", "expiring", "removing"] } }).select("owner billingNeverExpiresUntil").cursor();
|
||||
const deadlines = new Map<string, number>();
|
||||
const verified = new Set<string>();
|
||||
const deferred = new Set<string>();
|
||||
for await (const repo of cursor) {
|
||||
const owner = String(repo.owner);
|
||||
if (!deadlines.has(owner)) {
|
||||
const grants = await authorGrants(owner);
|
||||
deadlines.set(owner, Math.max(0, ...grants.filter(g => !g.revoked && g.until)
|
||||
.map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS)));
|
||||
}
|
||||
const previous = repo.billingNeverExpiresUntil!;
|
||||
let deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!));
|
||||
if (deadline <= now) {
|
||||
if (deferred.has(owner)) continue;
|
||||
try {
|
||||
if (!verified.has(owner)) {
|
||||
await reconcile(owner);
|
||||
verified.add(owner);
|
||||
const fresh = await authorGrants(owner);
|
||||
deadlines.set(owner, Math.max(0, ...fresh.filter(g => !g.revoked && g.until).map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS)));
|
||||
}
|
||||
deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!));
|
||||
} catch {
|
||||
deferred.add(owner);
|
||||
createLogger("billing").error("repository expiration deferred: billing verification failed", { owner });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const filter = { _id: repo._id, "options.expirationMode": "never", billingNeverExpiresUntil: previous };
|
||||
if (deadline <= now) {
|
||||
await Repo.updateOne(filter, { $set: { "options.expirationMode": "remove", "options.expirationDate": deadline },
|
||||
$unset: { billingNeverExpiresUntil: 1 } });
|
||||
} else if (deadline > previous) {
|
||||
await Repo.updateOne(filter, { $set: { billingNeverExpiresUntil: deadline } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBillingExpiration(options: { expirationMode?: string; expirationDate?: string | Date }) {
|
||||
if (!config.BILLING_ENABLED || options.expirationMode === "never") return;
|
||||
const date = options.expirationDate && new Date(options.expirationDate);
|
||||
const maximum = new Date();
|
||||
maximum.setFullYear(maximum.getFullYear() + 1);
|
||||
maximum.setHours(23, 59, 59, 999);
|
||||
if (!["remove", "redirect"].includes(options.expirationMode || "") || !date ||
|
||||
!Number.isFinite(date.getTime()) || date > maximum) throw billingError("invalid_billing_expiration");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as schedule from "node-schedule";
|
||||
import config from "../../config";
|
||||
import { BillingEvent, BillingAccount } from "./models";
|
||||
import { processBillingInbox, billingHealth, billingFailure } from "./events";
|
||||
import { reconcileBillingAccounts } from "./reconciliation";
|
||||
import { createLogger } from "../logger";
|
||||
const logger = createLogger("billing");
|
||||
export async function startBillingMaintenance() {
|
||||
if (!config.BILLING_ENABLED) return;
|
||||
await Promise.all([BillingEvent.createIndexes(), BillingAccount.createIndexes()]);
|
||||
let eventsRunning = false;
|
||||
let reconcileRunning = false;
|
||||
const events = async () => {
|
||||
if (eventsRunning) return;
|
||||
eventsRunning = true;
|
||||
try { await processBillingInbox(); }
|
||||
catch (error) { logger.error("billing worker failed", { code: billingFailure(error) }); }
|
||||
finally { eventsRunning = false; }
|
||||
};
|
||||
const reconcile = async () => {
|
||||
if (reconcileRunning) return;
|
||||
reconcileRunning = true;
|
||||
try {
|
||||
await reconcileBillingAccounts();
|
||||
const health = await billingHealth();
|
||||
if (health.failed || health.oldestPendingSeconds > 300 || health.unresolved || health.reconciliationFailed) logger.error("billing requires attention", health);
|
||||
// Remove routing envelopes after 90 days; retain failures for investigation.
|
||||
await BillingEvent.deleteMany({ state: "complete", completedAt: { $lt: new Date(Date.now() - 90 * 86400_000) } });
|
||||
} catch (error) { logger.error("billing maintenance failed", { code: billingFailure(error) }); }
|
||||
finally { reconcileRunning = false; }
|
||||
};
|
||||
schedule.scheduleJob("*/10 * * * * *", events);
|
||||
schedule.scheduleJob("*/5 * * * *", reconcile);
|
||||
void events();
|
||||
void reconcile();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Schema, model } from "mongoose";
|
||||
|
||||
// Billing records are separate from public user/repository serialization.
|
||||
export const BillingAccount = model("billingAccount", new Schema({
|
||||
_id: String, customerId: String,
|
||||
customerAttemptAt: Date,
|
||||
reconciledAt: Date, reconcileAfter: { type: Date, default: () => new Date(0), index: true }, reconcileError: String,
|
||||
checkout: { id: String, key: String, planId: String, target: String, expiresAt: Date, createdAt: Date, params: Schema.Types.Mixed },
|
||||
}));
|
||||
export const BillingGrant = model("billingGrant", new Schema({
|
||||
_id: String, owner: { type: String, index: true }, scope: { type: String, index: true },
|
||||
planId: String, customerId: String, subscriptionId: String, paymentIntentId: { type: String, index: true },
|
||||
status: String, until: Date, cancelAtPeriodEnd: Boolean,
|
||||
revision: { type: Number, default: 0 }, revoked: { type: Boolean, default: false },
|
||||
revocationReason: String, revokedAt: Date,
|
||||
}));
|
||||
export const BillingQuota = model("billingQuota", new Schema({
|
||||
_id: String,
|
||||
allocations: [{ _id: false, repoId: String, bytes: Number, createdAt: Date }],
|
||||
}));
|
||||
// A short lease serializes Stripe checkout creation across API instances.
|
||||
export const BillingLock = model("billingLock", new Schema({ _id: String, token: String, until: Date }));
|
||||
|
||||
const eventSchema = new Schema({
|
||||
_id: String, eventId: String, type: String, object: Schema.Types.Mixed,
|
||||
state: { type: String, default: "pending" }, attempts: { type: Number, default: 0 },
|
||||
receivedAt: { type: Date, default: Date.now }, nextAttemptAt: { type: Date, default: Date.now },
|
||||
leaseUntil: Date, token: String, completedAt: Date, lastError: String,
|
||||
});
|
||||
eventSchema.index({ state: 1, nextAttemptAt: 1, leaseUntil: 1 });
|
||||
export const BillingEvent = model("billingEvent", eventSchema);
|
||||
export const BillingAudit = model("billingAudit", new Schema({
|
||||
action: String, target: String, actor: String, reason: String, at: { type: Date, default: Date.now },
|
||||
}));
|
||||
|
||||
// A reversal can arrive before its grant exists. Keep the payment-level decision.
|
||||
export const BillingRevocation = model("billingRevocation", new Schema({
|
||||
_id: String, subscriptionId: String, reason: String, reviewed: { type: Boolean, default: false },
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
}));
|
||||
|
||||
// Short-lived, private task context. Explicit expiry checks also cover TTL sweep delays.
|
||||
export const BillingDraft = model("billingDraft", new Schema({
|
||||
_id: String, owner: { type: String, required: true, index: true }, path: String, values: Schema.Types.Mixed,
|
||||
expiresAt: { type: Date, required: true, expires: 0 },
|
||||
}));
|
||||
@@ -0,0 +1,58 @@
|
||||
import ConferenceModel from "../model/conference/conferences.model";
|
||||
import config from "../../config";
|
||||
import { BillingGrant, BillingQuota } from "./models";
|
||||
import { billingError, billingPlans } from "./config";
|
||||
import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
|
||||
export function usableGrant(grant: { status?: string | null; until?: Date | null; revoked?: boolean | null }, now = new Date()) {
|
||||
return !grant.revoked && !!grant.until && grant.until > now && ["active", "paid", "past_due"].includes(grant.status || "");
|
||||
}
|
||||
export async function allowance(owner: string, repoId?: string, conference?: string) {
|
||||
const scopes = [`user:${owner}`, ...(repoId ? [`repo:${repoId}`] : []), ...(conference ? [`conference:${conference}`] : [])];
|
||||
const grants = await BillingGrant.find({ scope: { $in: scopes }, until: { $gt: new Date() }, revoked: false }).lean();
|
||||
// A conference pays before the author. There is no double charging of allowance.
|
||||
for (const scope of scopes.reverse()) {
|
||||
const grant = grants.find(g => g.scope === scope && usableGrant(g));
|
||||
const plan = grant && billingPlans().find(p => p.id === grant.planId);
|
||||
if (plan) return { scope, repositories: plan.repositories, storage: plan.storage, plan: plan.id };
|
||||
}
|
||||
return { scope: `user:${owner}`, repositories: config.FREE_AUTHOR_REPOSITORIES, storage: config.FREE_AUTHOR_STORAGE, plan: "free" };
|
||||
}
|
||||
export function allocationExpression(repoId: string) {
|
||||
return { $filter: { input: "$allocations", as: "allocation", cond: { $ne: ["$$allocation.repoId", { $literal: repoId }] } } };
|
||||
}
|
||||
export async function reserveRepository(owner: string, repoId: string, bytes: number, conference?: string) {
|
||||
if (!config.BILLING_ENABLED) return;
|
||||
if (!Number.isSafeInteger(bytes) || bytes < 0) throw billingError("billing_invalid_repository_size");
|
||||
const limits = await allowance(owner, repoId, conference);
|
||||
// Seed existing repositories once. Legacy review links are preserved, but they
|
||||
// count toward the owner's capacity when creating new work.
|
||||
if (!await BillingQuota.exists({ _id: limits.scope })) {
|
||||
const existing = await RepositoryModel.find(limits.scope.startsWith("user:") ? { owner, status: { $nin: ["removed", "expired"] } }
|
||||
: limits.scope.startsWith("conference:") ? { conference, status: { $nin: ["removed", "expired"] } } : { repoId, status: { $nin: ["removed", "expired"] } })
|
||||
.select("repoId size").lean();
|
||||
try { await BillingQuota.create({ _id: limits.scope, allocations: existing.map(r => ({ repoId: r.repoId, bytes: r.size?.storage || 0, createdAt: new Date() })) }); }
|
||||
catch (error) { if ((error as { code?: number }).code !== 11000) throw error; }
|
||||
}
|
||||
// Only confirmed terminal repositories release capacity. Unknown reservations
|
||||
// remain until reconciled by an operator, so a crash cannot oversell capacity.
|
||||
const ledger = await BillingQuota.findById(limits.scope).lean();
|
||||
const terminal = await RepositoryModel.find({ repoId: { $in: ledger?.allocations.map(a => a.repoId) || [] }, status: "removed" }).select("repoId").lean();
|
||||
if (terminal.length) await BillingQuota.updateOne({ _id: limits.scope }, { $pull: { allocations: { repoId: { $in: terminal.map(r => r.repoId) } } } });
|
||||
const others = allocationExpression(repoId);
|
||||
// Never shrink a reservation during refresh. Concurrent generations and
|
||||
// failed builds cannot free bytes still needed by the published generation.
|
||||
const reservedBytes = { $max: [bytes, { $sum: { $map: { input: { $filter: { input: "$allocations", as: "a", cond: { $eq: ["$$a.repoId", { $literal: repoId }] } } }, as: "a", in: "$$a.bytes" } } }] };
|
||||
const result = await BillingQuota.updateOne({ _id: limits.scope, $expr: { $and: [
|
||||
{ $lt: [{ $size: others }, limits.repositories] },
|
||||
{ $lte: [{ $add: [{ $sum: { $map: { input: others, as: "a", in: "$$a.bytes" } } }, reservedBytes] }, limits.storage] },
|
||||
] } }, [{ $set: { allocations: { $concatArrays: [others, [{ repoId: { $literal: repoId }, bytes: reservedBytes, createdAt: new Date() }]] } } }]);
|
||||
if (!result.matchedCount) throw billingError("quota_exceeded", 402);
|
||||
}
|
||||
|
||||
export async function assertConferenceAdmission(owner: string, repoId: string, conference?: string) {
|
||||
if (!conference || !config.BILLING_ENABLED) return;
|
||||
const conf = await ConferenceModel.findOne({ conferenceID: conference }).lean();
|
||||
if (!conf || conf.status !== "ready" || conf.startDate > new Date() || conf.endDate <= new Date()) throw billingError("conf_not_activated");
|
||||
if (conf.plan.planID === "conference" && (await allowance(owner, repoId, conference)).scope !== `conference:${conference}`) throw billingError("conference_payment_required", 402);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BillingAccount } from "./models";
|
||||
import Stripe = require("stripe");
|
||||
import { fulfillCheckout, syncSubscription, stripeClient, processBillingEvent, withBillingLock } from "./stripe";
|
||||
import { billingFailure } from "./events";
|
||||
import { createLogger } from "../logger";
|
||||
const logger = createLogger("billing");
|
||||
|
||||
// Paginate provider history so a missing local grant is discoverable after restore
|
||||
// or lost delivery. Only paid customer accounts are scanned, once per day.
|
||||
export async function reconcileBillingAccount(owner: string, stripe = stripeClient()) {
|
||||
return withBillingLock(`reconcile:${owner}`, async () => {
|
||||
const account = await BillingAccount.findById(owner);
|
||||
if (!account?.customerId) throw new Error("billing_customer_missing");
|
||||
for await (const sub of stripe.subscriptions.list({ customer: account.customerId, status: "all", limit: 100 })) {
|
||||
await syncSubscription(sub.id, stripe);
|
||||
}
|
||||
for await (const session of stripe.checkout.sessions.list({ customer: account.customerId, limit: 100 })) {
|
||||
if (session.metadata?.application !== "anonymous-github" || session.metadata.owner !== owner) continue;
|
||||
if (account.checkout?.key && !account.checkout.id && session.metadata.attempt === account.checkout.key) {
|
||||
await BillingAccount.updateOne({ _id: owner, "checkout.key": account.checkout.key }, { $set: { "checkout.id": session.id } });
|
||||
}
|
||||
if (session.status === "complete") await fulfillCheckout(session.id, stripe);
|
||||
}
|
||||
// Refunds on older invoices must also survive lost webhook deliveries.
|
||||
for await (const charge of stripe.charges.list({ customer: account.customerId, limit: 100 })) {
|
||||
if (charge.refunded || charge.disputed) await processBillingEvent({ type: "charge.refunded", data: { object: { ...charge, refunded: true } } } as Stripe.Event, stripe);
|
||||
}
|
||||
await BillingAccount.updateOne({ _id: owner }, { $set: { reconciledAt: new Date(), reconcileAfter: new Date(Date.now() + 86400_000) }, $unset: { reconcileError: 1 } });
|
||||
});
|
||||
}
|
||||
export async function reconcileBillingAccounts(limit = 10) {
|
||||
const accounts = await BillingAccount.find({ customerId: { $exists: true }, $or: [
|
||||
{ reconcileAfter: { $lte: new Date() } }, { reconcileAfter: { $exists: false } },
|
||||
] }).sort({ reconcileAfter: 1 }).limit(limit).lean();
|
||||
for (const account of accounts) {
|
||||
try { await reconcileBillingAccount(account._id!); }
|
||||
catch (error) {
|
||||
const code = billingFailure(error);
|
||||
await BillingAccount.updateOne({ _id: account._id }, { $set: { reconcileAfter: new Date(Date.now() + 900_000), reconcileError: code } });
|
||||
logger.error("billing reconciliation deferred", { owner: account._id, code });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverBillingCustomers(stripe = stripeClient()) {
|
||||
const Users = (await import("../model/users/users.model")).default;
|
||||
for await (const customer of stripe.customers.list({ limit: 100 })) {
|
||||
const owner = customer.metadata.owner;
|
||||
if (customer.metadata.application !== "anonymous-github" || !owner || !/^[a-f0-9]{24}$/i.test(owner)) continue;
|
||||
if (!await Users.exists({ _id: owner })) continue;
|
||||
const current = await BillingAccount.findById(owner);
|
||||
if (current?.customerId && current.customerId !== customer.id) {
|
||||
logger.error("duplicate Stripe customer requires review", { owner });
|
||||
continue;
|
||||
}
|
||||
await BillingAccount.updateOne({ _id: owner, $or: [{ customerId: { $exists: false } }, { customerId: customer.id }] },
|
||||
{ $set: { customerId: customer.id, reconcileAfter: new Date(0) } }, { upsert: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Stripe = require("stripe");
|
||||
import { randomUUID } from "crypto";
|
||||
import config from "../../config";
|
||||
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation } from "./models";
|
||||
import { billingError, billingOrigin, billingPlans, requireBilling } from "./config";
|
||||
import ConferenceModel from "../model/conference/conferences.model";
|
||||
import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
|
||||
export function stripeClient() { requireBilling(); return new Stripe(config.STRIPE_SECRET_KEY, { maxNetworkRetries: 2, timeout: 10_000 }); }
|
||||
const objectId = (value: string | { id: string } | null) => typeof value === "string" ? value : value?.id;
|
||||
export async function withBillingLock<T>(id: string, work: () => Promise<T>): Promise<T> {
|
||||
const token = randomUUID();
|
||||
try { await BillingLock.updateOne({ _id: id, until: { $lt: new Date() } }, { $set: { token, until: new Date(Date.now() + 180_000) } }, { upsert: true }); }
|
||||
catch (error) { if ((error as { code?: number }).code === 11000) throw billingError("billing_busy", 409); throw error; }
|
||||
const heartbeat = setInterval(() => {
|
||||
BillingLock.updateOne({ _id: id, token }, { $set: { until: new Date(Date.now() + 180_000) } }).catch(() => undefined);
|
||||
}, 30_000);
|
||||
heartbeat.unref();
|
||||
try { return await work(); }
|
||||
finally { clearInterval(heartbeat); await BillingLock.deleteOne({ _id: id, token }); }
|
||||
}
|
||||
export async function syncSubscription(id: string, stripe = stripeClient()) {
|
||||
// Compare-and-swap prevents a slow, earlier fetch from overwriting a newer one.
|
||||
for (let retry = 0; retry < 4; retry++) {
|
||||
const previous = await BillingGrant.findById(id).lean();
|
||||
const sub = await stripe.subscriptions.retrieve(id, { expand: ["latest_invoice"] });
|
||||
const owner = sub.metadata.owner;
|
||||
if (!owner || sub.metadata.application !== "anonymous-github") return;
|
||||
const account = await BillingAccount.findById(owner).lean();
|
||||
if (!account || account.customerId !== objectId(sub.customer)) throw billingError("billing_customer_mismatch");
|
||||
const priceId = sub.items.data[0]?.price.id;
|
||||
const plan = billingPlans().find(p => p.recurring && p.priceId === priceId);
|
||||
const invoice = sub.latest_invoice as Stripe.Invoice | null;
|
||||
const paid = !!plan && sub.items.data.length === 1 && sub.status === "active" && invoice?.status === "paid";
|
||||
const periodEnd = sub.items.data[0]?.current_period_end;
|
||||
let paidThrough = paid && periodEnd ? new Date(periodEnd * 1000) : previous?.until || new Date(0);
|
||||
// A missed paid renewal followed by a failed invoice must be recoverable.
|
||||
// Only matching subscription-item service periods count, not arbitrary invoices.
|
||||
if (!paid && plan && sub.items.data.length === 1 && stripe.invoices?.list) {
|
||||
for await (const historical of stripe.invoices.list({ subscription: id, status: "paid", limit: 100 })) {
|
||||
for await (const line of stripe.invoices.listLineItems(historical.id, { limit: 100 })) {
|
||||
if (!line.parent?.subscription_item_details?.proration && line.amount >= 0 &&
|
||||
line.parent?.subscription_item_details?.subscription_item === sub.items.data[0].id &&
|
||||
line.period.end * 1000 > paidThrough.getTime()) paidThrough = new Date(line.period.end * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
const values = { owner, scope: `user:${owner}`, planId: plan?.id || "unknown", customerId: account.customerId,
|
||||
subscriptionId: id, status: sub.status, cancelAtPeriodEnd: sub.cancel_at_period_end || !!sub.cancel_at,
|
||||
until: paidThrough,
|
||||
};
|
||||
if (!previous) {
|
||||
try { await BillingGrant.create({ _id: id, ...values }); return; }
|
||||
catch (error) { if ((error as { code?: number }).code !== 11000) throw error; }
|
||||
} else {
|
||||
const result = await BillingGrant.updateOne({ _id: id, revision: previous.revision }, { $set: values, $inc: { revision: 1 } });
|
||||
if (result.matchedCount) return;
|
||||
}
|
||||
}
|
||||
throw billingError("billing_busy", 409);
|
||||
}
|
||||
export async function fulfillCheckout(id: string, stripe = stripeClient()) {
|
||||
const session = await stripe.checkout.sessions.retrieve(id, { expand: ["line_items", "payment_intent.latest_charge"] });
|
||||
if (session.metadata?.application !== "anonymous-github") return;
|
||||
const { owner, planId, target } = session.metadata;
|
||||
const account = await BillingAccount.findById(owner).lean();
|
||||
if (!account || account.customerId !== objectId(session.customer)) throw billingError("billing_customer_mismatch");
|
||||
if (session.status !== "complete") return;
|
||||
if (session.subscription) { await syncSubscription(objectId(session.subscription)!, stripe); return; }
|
||||
const plan = billingPlans().find(p => p.id === planId && !p.recurring);
|
||||
if (!plan || session.line_items?.data.length !== 1 || session.line_items.data[0].price?.id !== plan.priceId || session.line_items.data[0].quantity !== 1) throw billingError("billing_invalid_purchase");
|
||||
if (session.payment_status !== "paid" || session.status !== "complete") return;
|
||||
const intent = session.payment_intent as Stripe.PaymentIntent | null;
|
||||
const charge = intent?.latest_charge as Stripe.Charge | null;
|
||||
const revoked = !!charge && (charge.refunded || charge.disputed);
|
||||
const scope = plan.id === "conference" ? `conference:${target}` : `repo:${target}`;
|
||||
let until = new Date(session.created * 1000 + 180 * 86400_000);
|
||||
if (plan.id === "conference") {
|
||||
const conference = await ConferenceModel.findOne({ conferenceID: target });
|
||||
// Historical sessions can outlive a deliberately deleted conference.
|
||||
if (!conference) return;
|
||||
until = conference.endDate;
|
||||
}
|
||||
await BillingGrant.updateOne({ _id: id }, { $setOnInsert: { owner, scope, planId, customerId: account.customerId,
|
||||
paymentIntentId: objectId(session.payment_intent), status: "paid", until }, ...(revoked ? { $set: { revoked: true } } : {}) }, { upsert: true });
|
||||
const reversal = intent && await BillingRevocation.findById(intent.id).lean();
|
||||
if (reversal?.reviewed) await BillingGrant.updateOne({ _id: id }, { $set: { revoked: false } });
|
||||
else if (reversal) await BillingGrant.updateOne({ _id: id }, { $set: { revoked: true, revocationReason: reversal.reason } });
|
||||
if (plan.id === "conference" && (!revoked || reversal?.reviewed)) {
|
||||
const grant = await BillingGrant.findById(id).lean();
|
||||
if (!grant?.revoked) await ConferenceModel.updateOne({ conferenceID: target, status: "pending_payment", "plan.planID": "conference" }, { $set: { status: "ready" } });
|
||||
}
|
||||
}
|
||||
export async function processBillingEvent(event: Stripe.Event, stripe = stripeClient()) {
|
||||
if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") {
|
||||
await fulfillCheckout(event.data.object.id, stripe);
|
||||
} else if (event.type.startsWith("customer.subscription.")) {
|
||||
await syncSubscription((event.data.object as Stripe.Subscription).id, stripe);
|
||||
} else if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const id = objectId(invoice.parent?.subscription_details?.subscription || null);
|
||||
if (id) await syncSubscription(id, stripe);
|
||||
} else if (event.type === "charge.refunded" || event.type === "charge.dispute.created") {
|
||||
const charge = event.type === "charge.refunded" ? event.data.object as Stripe.Charge
|
||||
: await stripe.charges.retrieve(objectId((event.data.object as Stripe.Dispute).charge)!);
|
||||
// Partial goodwill refunds preserve access. Full refunds and disputes revoke.
|
||||
if (event.type === "charge.refunded" && charge.refunded === false) return;
|
||||
const intent = objectId(charge.payment_intent);
|
||||
if (!intent) return;
|
||||
const reason = event.type === "charge.dispute.created" || charge.disputed ? "dispute" : "full_refund";
|
||||
await BillingRevocation.updateOne({ _id: intent }, { $setOnInsert: { reason } }, { upsert: true });
|
||||
// A dispute escalates a reviewed refund. An older refund replay must not
|
||||
// undo the operator's review of that dispute.
|
||||
if (reason === "dispute") await BillingRevocation.updateOne({ _id: intent, reason: "full_refund" }, { $set: { reason, reviewed: false } });
|
||||
const reversal = await BillingRevocation.findById(intent).lean();
|
||||
if (reversal?.reviewed) return;
|
||||
const revocation = { revoked: true, revocationReason: reason, revokedAt: new Date() };
|
||||
await BillingGrant.updateMany({ paymentIntentId: intent }, { $set: revocation, $inc: { revision: 1 } });
|
||||
// Resolve the affected subscription, not every subscription owned by this payer.
|
||||
let after: string | undefined;
|
||||
do {
|
||||
const payments = await stripe.invoicePayments.list({ payment: { type: "payment_intent", payment_intent: intent }, limit: 100, ...(after ? { starting_after: after } : {}) });
|
||||
for (const payment of payments.data) {
|
||||
const invoice = await stripe.invoices.retrieve(objectId(payment.invoice)!);
|
||||
const subscriptionId = objectId(invoice.parent?.subscription_details?.subscription || null);
|
||||
if (subscriptionId) {
|
||||
await BillingRevocation.updateOne({ _id: intent }, { $set: { subscriptionId } });
|
||||
await BillingGrant.updateOne({ _id: subscriptionId }, { $set: revocation, $inc: { revision: 1 } }, { upsert: true });
|
||||
}
|
||||
}
|
||||
after = payments.has_more ? payments.data.at(-1)?.id : undefined;
|
||||
} while (after);
|
||||
}
|
||||
}
|
||||
export async function createCheckout(owner: string, planId: string, target = "", stripe = stripeClient(), returnPath?: string) {
|
||||
if (!config.BILLING_CHECKOUT_ENABLED) throw billingError("billing_checkout_disabled", 503);
|
||||
const plan = billingPlans().find(p => p.id === planId && p.priceId);
|
||||
if (!plan) throw billingError("billing_plan_unavailable");
|
||||
if (plan.id === "conference") {
|
||||
const conf = await ConferenceModel.findOne({ conferenceID: target, owners: owner });
|
||||
if (!conf || conf.plan.planID !== "conference" || conf.status !== "pending_payment") throw billingError("billing_invalid_conference");
|
||||
if (conf.endDate <= new Date() || conf.endDate.getTime() - conf.startDate.getTime() > 180 * 86400_000) throw billingError("billing_invalid_review_window");
|
||||
} else if (plan.id === "author_pass") {
|
||||
const repo = await RepositoryModel.exists({ repoId: target, owner, status: { $nin: ["removed", "expired"] } });
|
||||
if (!repo) throw billingError("repo_not_found", 404);
|
||||
} else target = "";
|
||||
const destination = returnPath || (plan.id === "conference" ? `/conference/${encodeURIComponent(target)}` : "/profile");
|
||||
if (returnPath && !/^\/anonymize(?:\/[A-Za-z0-9_-]{1,200})?\?billingDraft=[a-f0-9-]{36}$/.test(returnPath)) throw billingError("billing_invalid_draft");
|
||||
const outcomeUrl = (outcome: string) => `${billingOrigin()}${destination}${destination.includes("?") ? "&" : "?"}billing=${outcome}`;
|
||||
return withBillingLock(`checkout:${owner}`, async () => {
|
||||
let account = await BillingAccount.findById(owner);
|
||||
if (!account) account = await BillingAccount.create({ _id: owner });
|
||||
if (!account.customerId) {
|
||||
if (account.customerAttemptAt && Date.now() - account.customerAttemptAt.getTime() >= 23 * 3600_000) {
|
||||
// A customer creation timeout must not cause a duplicate after key pruning.
|
||||
throw billingError("billing_recovery_required", 409);
|
||||
}
|
||||
account.customerAttemptAt ||= new Date();
|
||||
await account.save();
|
||||
const customer = await stripe.customers.create({ metadata: { application: "anonymous-github", owner } }, { idempotencyKey: `customer:${owner}` });
|
||||
account.customerId = customer.id;
|
||||
await account.save();
|
||||
}
|
||||
if (account.checkout?.key && !account.checkout.id &&
|
||||
(!account.checkout.createdAt || Date.now() - account.checkout.createdAt.getTime() >= 23 * 3600_000)) {
|
||||
for await (const candidate of stripe.checkout.sessions.list({ customer: account.customerId, limit: 100 })) {
|
||||
if (candidate.metadata?.attempt === account.checkout.key && candidate.metadata.owner === owner) {
|
||||
account.checkout.id = candidate.id;
|
||||
await account.save();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!account.checkout.id) {
|
||||
// After the provider key window, an expired immutable request can no
|
||||
// longer create a session. Legacy attempts without expiry need review.
|
||||
if (account.checkout.params?.expires_at && account.checkout.createdAt &&
|
||||
Date.now() - account.checkout.createdAt.getTime() > 24 * 3600_000 &&
|
||||
account.checkout.params.expires_at * 1000 < Date.now()) {
|
||||
account.checkout = undefined;
|
||||
await account.save();
|
||||
} else throw billingError("billing_recovery_required", 409);
|
||||
}
|
||||
}
|
||||
if (account.checkout?.params?.success_url && !account.checkout.id && account.checkout.params.success_url !== outcomeUrl("success")) throw billingError("billing_checkout_in_progress", 409);
|
||||
if (account.checkout?.id) {
|
||||
const existing = await stripe.checkout.sessions.retrieve(account.checkout.id);
|
||||
if (existing.status === "open") {
|
||||
if (account.checkout.planId !== planId || account.checkout.target !== target) throw billingError("billing_checkout_in_progress", 409);
|
||||
if (account.checkout.params?.success_url && account.checkout.params.success_url !== outcomeUrl("success")) throw billingError("billing_checkout_in_progress", 409);
|
||||
return existing.url;
|
||||
}
|
||||
if (existing.status === "complete") await fulfillCheckout(existing.id, stripe);
|
||||
}
|
||||
const price = await stripe.prices.retrieve(plan.priceId);
|
||||
const interval = plan.id === "author_monthly" ? "month" : "year";
|
||||
if (!price.active || price.currency !== "eur" || price.unit_amount == null || price.unit_amount <= 0 ||
|
||||
(plan.recurring ? price.recurring?.interval !== interval || price.recurring?.interval_count !== 1 : !!price.recurring)) throw billingError("billing_price_misconfigured", 503);
|
||||
if (plan.recurring) {
|
||||
let after: string | undefined;
|
||||
do {
|
||||
const subscriptions = await stripe.subscriptions.list({ customer: account.customerId, status: "all", limit: 100, ...(after ? { starting_after: after } : {}) });
|
||||
if (subscriptions.data.some(s => !["canceled", "incomplete_expired"].includes(s.status))) throw billingError("billing_subscription_exists", 409);
|
||||
after = subscriptions.has_more ? subscriptions.data.at(-1)?.id : undefined;
|
||||
} while (after);
|
||||
} else if (await BillingGrant.exists({ scope: `${plan.id === "conference" ? "conference" : "repo"}:${target}`, revoked: false, until: { $gt: new Date() } })) throw billingError("billing_already_paid", 409);
|
||||
// Persist the idempotency key before sending a request. A timeout can safely retry it.
|
||||
if (!account.checkout?.key || account.checkout.id) {
|
||||
account.checkout = { key: randomUUID(), planId, target, createdAt: new Date(), expiresAt: new Date(Date.now() + 1860_000) };
|
||||
await account.save();
|
||||
}
|
||||
if (account.checkout.planId !== planId || account.checkout.target !== target) throw billingError("billing_checkout_in_progress", 409);
|
||||
const metadata = { application: "anonymous-github", owner, planId, target, attempt: account.checkout.key! };
|
||||
const params: Stripe.Checkout.SessionCreateParams = { customer: account.customerId, expires_at: Math.floor(Date.now() / 1000) + 1860,
|
||||
mode: plan.recurring ? "subscription" : "payment", line_items: [{ price: plan.priceId, quantity: 1 }],
|
||||
allowed_payment_method_types: ["card"], metadata,
|
||||
...(plan.recurring ? { subscription_data: { metadata } } : { payment_intent_data: { metadata }, invoice_creation: { enabled: true } }),
|
||||
billing_address_collection: "required", tax_id_collection: { enabled: true }, customer_update: { address: "auto", name: "auto" },
|
||||
automatic_tax: { enabled: config.BILLING_AUTOMATIC_TAX },
|
||||
success_url: outcomeUrl("success"), cancel_url: outcomeUrl("cancelled"),
|
||||
};
|
||||
// Persist the exact parameters: configuration changes cannot alter a retry.
|
||||
if (!account.checkout.params) {
|
||||
account.checkout.params = params;
|
||||
await account.save();
|
||||
}
|
||||
const session = await stripe.checkout.sessions.create(account.checkout.params as Stripe.Checkout.SessionCreateParams, { idempotencyKey: account.checkout.key });
|
||||
account.checkout.id = session.id;
|
||||
await account.save();
|
||||
return session.url;
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelAccountBilling(owner: string) {
|
||||
const account = await BillingAccount.findById(owner).lean();
|
||||
if (!account?.customerId) return;
|
||||
if (account.checkout?.key && !account.checkout.id) throw billingError("billing_checkout_in_progress", 409);
|
||||
await withBillingLock(`checkout:${owner}`, async () => {
|
||||
const account = await BillingAccount.findById(owner).lean();
|
||||
if (!account?.customerId) return;
|
||||
if (account.checkout?.key && !account.checkout.id) throw billingError("billing_checkout_in_progress", 409);
|
||||
const stripe = stripeClient();
|
||||
if (account.checkout?.id) {
|
||||
const session = await stripe.checkout.sessions.retrieve(account.checkout.id);
|
||||
if (session.status === "open") await stripe.checkout.sessions.expire(session.id);
|
||||
}
|
||||
for await (const subscription of stripe.subscriptions.list({ customer: account.customerId, status: "all", limit: 100 })) {
|
||||
if (!["canceled", "incomplete_expired"].includes(subscription.status)) await stripe.subscriptions.cancel(subscription.id);
|
||||
}
|
||||
await BillingGrant.updateMany({ owner, subscriptionId: { $exists: true } }, { $set: { status: "canceled" }, $inc: { revision: 1 } });
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { repositoryAccessSchema } from "../repository-access.schema";
|
||||
import { Schema } from "mongoose";
|
||||
|
||||
const AnonymizedRepositorySchema = new Schema({
|
||||
billingManaged: Boolean,
|
||||
billingNeverExpiresUntil: Date,
|
||||
repoId: {
|
||||
type: String,
|
||||
index: { unique: true, collation: { locale: "en", strength: 2 } },
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Document, Model } from "mongoose";
|
||||
import { RepositoryStatus } from "../../types";
|
||||
|
||||
export interface IAnonymizedRepository {
|
||||
billingManaged?: boolean;
|
||||
billingNeverExpiresUntil?: Date;
|
||||
repoId: string;
|
||||
status?: RepositoryStatus;
|
||||
statusMessage?: string;
|
||||
|
||||
+1
-1
@@ -17,6 +17,6 @@ export enum RepositoryStatus {
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
export type ConferenceStatus = "ready" | "expired" | "removed";
|
||||
export type ConferenceStatus = "ready" | "expired" | "removed" | "pending_payment";
|
||||
|
||||
export type SourceStatus = "available" | "unavailable";
|
||||
@@ -0,0 +1,43 @@
|
||||
import "dotenv/config";
|
||||
import mongoose from "mongoose";
|
||||
import config from "../config";
|
||||
import { BillingAccount, BillingEvent, BillingAudit, BillingRevocation, BillingGrant } from "../core/billing/models";
|
||||
import { billingHealth, processBillingInbox } from "../core/billing/events";
|
||||
import { reconcileBillingAccount, discoverBillingCustomers } from "../core/billing/reconciliation";
|
||||
|
||||
async function main() {
|
||||
const [command, target, reason] = process.argv.slice(2);
|
||||
if (!["health", "inspect", "replay", "reconcile", "discover", "drain", "reinstate"].includes(command)) throw new Error("invalid_command");
|
||||
const mutations = ["replay", "reconcile", "discover", "drain", "reinstate"].includes(command);
|
||||
if (mutations && (!process.env.BILLING_OPERATOR || !reason)) throw new Error("operator_and_reason_required");
|
||||
const uri = config.MONGODB_URI || `mongodb://${config.DB_USERNAME}:${config.DB_PASSWORD}@${config.DB_HOSTNAME}:27017/production`;
|
||||
await mongoose.connect(uri, { autoIndex: false, ...(config.MONGODB_URI ? {} : { authSource: "admin" }) });
|
||||
if (mutations) await BillingAudit.create({ action: command, target, reason, actor: process.env.BILLING_OPERATOR });
|
||||
if (command === "reinstate") {
|
||||
const reversal = await BillingRevocation.findById(target);
|
||||
if (!reversal) throw new Error("payment_reversal_missing");
|
||||
reversal.reviewed = true;
|
||||
await reversal.save();
|
||||
if (reversal.subscriptionId && await BillingRevocation.exists({ subscriptionId: reversal.subscriptionId, reviewed: false })) {
|
||||
console.log("This payment is reviewed; other unresolved reversals still suspend the subscription.");
|
||||
return;
|
||||
}
|
||||
const affected = reversal.subscriptionId ? { _id: reversal.subscriptionId } : { paymentIntentId: target };
|
||||
await BillingGrant.updateMany(affected, { $set: { revoked: false }, $unset: { revocationReason: 1, revokedAt: 1 }, $inc: { revision: 1 } });
|
||||
console.log("Reinstatement recorded. Reconcile the owner to verify current paid time.");
|
||||
}
|
||||
if (command === "health") console.log(JSON.stringify(await billingHealth(), null, 2));
|
||||
if (command === "inspect") console.log(JSON.stringify(await BillingAccount.findById(target).select("customerId reconciledAt reconcileError checkout.id checkout.createdAt"), null, 2));
|
||||
if (command === "reconcile") await reconcileBillingAccount(target);
|
||||
if (command === "discover") await discoverBillingCustomers();
|
||||
if (command === "drain") await processBillingInbox(100);
|
||||
if (command === "replay") {
|
||||
const result = await BillingEvent.updateOne({ _id: target, state: { $in: ["failed", "complete", "pending"] } },
|
||||
{ $set: { state: "pending", attempts: 0, nextAttemptAt: new Date() }, $unset: { completedAt: 1, lastError: 1 } });
|
||||
if (!result.matchedCount) throw new Error("event_not_replayable");
|
||||
}
|
||||
}
|
||||
main().catch(() => {
|
||||
console.error("Billing operation failed. Check the command, target, BILLING_OPERATOR, reason, and server configuration. No credentials were logged.");
|
||||
process.exitCode = 1;
|
||||
}).finally(() => mongoose.disconnect());
|
||||
@@ -1,3 +1,5 @@
|
||||
import { startBillingMaintenance } from "../core/billing/maintenance";
|
||||
import billing, { billingWebhook } from "./routes/billing";
|
||||
import { githubAppRouter, githubAppWebhook } from "./routes/github-app";
|
||||
import { config as dotenv } from "dotenv";
|
||||
dotenv();
|
||||
@@ -107,6 +109,7 @@ export default async function start() {
|
||||
startPerformanceMonitoring("api", getAnonymizationPoolStats);
|
||||
app.set("query parser", "extended");
|
||||
app.use("/github/app/webhook", githubAppWebhook);
|
||||
app.use("/api/billing/webhook", billingWebhook);
|
||||
app.use(express.json());
|
||||
// Preserve the empty body used by API validation when no JSON was parsed.
|
||||
app.use((req, _res, next) => {
|
||||
@@ -210,6 +213,7 @@ export default async function start() {
|
||||
const apiRouter = express.Router();
|
||||
app.use("/api", rate, apiRouter);
|
||||
|
||||
apiRouter.use("/billing", billing);
|
||||
apiRouter.use("/admin", router.admin);
|
||||
apiRouter.use("/options", router.option);
|
||||
apiRouter.use("/conferences", router.conference);
|
||||
@@ -300,6 +304,7 @@ export default async function start() {
|
||||
dailyStatsSnapshot();
|
||||
|
||||
await connect();
|
||||
await startBillingMaintenance();
|
||||
await startTemporaryStorageMaintenance();
|
||||
app.listen(config.PORT);
|
||||
logger.info("server started", { port: config.PORT });
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as express from "express";
|
||||
import { randomBytes } from "crypto";
|
||||
import { ensureAuthenticated } from "./connection";
|
||||
import { getUser, handleError } from "./route-utils";
|
||||
import config from "../../config";
|
||||
import { BillingAccount, BillingGrant } from "../../core/billing/models";
|
||||
import { billingOrigin, billingPlans, requireBilling } from "../../core/billing/config";
|
||||
import { createCheckout, stripeClient, fulfillCheckout, syncSubscription } from "../../core/billing/stripe";
|
||||
import { saveBillingDraft, getBillingDraft } from "../../core/billing/drafts";
|
||||
import { allowance } from "../../core/billing/quotas";
|
||||
|
||||
import { receiveBillingEvent, billingFailure } from "../../core/billing/events";
|
||||
import { createLogger } from "../../core/logger";
|
||||
import { rateLimit } from "express-rate-limit";
|
||||
|
||||
declare module "express-session" { interface SessionData { billingCSRF?: string } }
|
||||
export const billingWebhook = express.Router();
|
||||
billingWebhook.post("/", express.raw({ type: "application/json", limit: "256kb" }), async (req, res) => {
|
||||
if (!config.BILLING_ENABLED) { res.sendStatus(404); return; }
|
||||
let event;
|
||||
try { event = stripeClient().webhooks.constructEvent(req.body, req.headers["stripe-signature"] as string, config.STRIPE_WEBHOOK_SECRET); }
|
||||
catch { res.status(400).json({ error: "invalid_billing_signature" }); return; }
|
||||
if (event.account || (typeof event.livemode === "boolean" && event.livemode !== /^(sk|rk)_live_/.test(config.STRIPE_SECRET_KEY))) {
|
||||
res.status(400).json({ error: "billing_environment_mismatch" }); return;
|
||||
}
|
||||
try { await receiveBillingEvent(event); res.json({ received: true }); }
|
||||
catch (error) { createLogger("billing").error("billing event storage failed", { eventId: event.id, code: billingFailure(error) }); res.status(500).json({ error: "billing_webhook_failed" }); }
|
||||
});
|
||||
const priceCache = new Map<string, { expires: number; value: Awaited<ReturnType<ReturnType<typeof stripeClient>["prices"]["retrieve"]>> }>();
|
||||
async function displayedPrice(id: string) {
|
||||
const cached = priceCache.get(id);
|
||||
if (cached && cached.expires > Date.now()) return cached.value;
|
||||
const value = await stripeClient().prices.retrieve(id);
|
||||
priceCache.set(id, { expires: Date.now() + 60_000, value });
|
||||
return value;
|
||||
}
|
||||
const router = express.Router();
|
||||
router.use(ensureAuthenticated);
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
req.session.billingCSRF ||= randomBytes(32).toString("hex");
|
||||
await new Promise<void>((resolve, reject) => req.session.save(error => error ? reject(error) : resolve()));
|
||||
const account = await BillingAccount.findById(user.id).lean();
|
||||
const grants = await BillingGrant.find({ owner: user.id }).select("planId scope status until cancelAtPeriodEnd revoked").lean();
|
||||
const plans = config.BILLING_ENABLED ? await Promise.all(billingPlans().filter(p => p.priceId).map(async p => {
|
||||
const price = await displayedPrice(p.priceId);
|
||||
return { id: p.id, name: p.name, description: p.description, repositories: p.repositories, storage: p.storage,
|
||||
amount: price.unit_amount, currency: price.currency, interval: price.recurring?.interval, intervalCount: price.recurring?.interval_count };
|
||||
})) : [];
|
||||
res.set("Cache-Control", "no-store").json({ enabled: config.BILLING_ENABLED, csrf: req.session.billingCSRF, plans, checkoutEnabled: config.BILLING_CHECKOUT_ENABLED,
|
||||
grants: grants.map(g => ({ planId: g.planId, scope: g.scope, status: g.revoked ? "revoked" : g.status, until: g.until, cancelAtPeriodEnd: g.cancelAtPeriodEnd })),
|
||||
portal: !!account?.customerId, allowance: config.BILLING_ENABLED ? await allowance(user.id) : null });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
router.get("/draft/:id", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
const draft = await getBillingDraft(user.id, req.params.id);
|
||||
res.set("Cache-Control", "no-store").json({ id: draft._id, path: draft.path, values: draft.values });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
router.use((req, res, next) => {
|
||||
if (!req.session.billingCSRF || req.headers["x-csrf-token"] !== req.session.billingCSRF) { res.status(403).json({ error: "invalid_auth_state" }); return; }
|
||||
next();
|
||||
});
|
||||
router.use(rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: "draft-8", legacyHeaders: false }));
|
||||
router.post("/draft", async (req, res) => {
|
||||
try {
|
||||
requireBilling();
|
||||
const user = await getUser(req);
|
||||
const draft = await saveBillingDraft(user.id, req.body.path, req.body.values);
|
||||
res.set("Cache-Control", "no-store").json({ id: draft._id });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
router.post("/checkout", async (req, res) => {
|
||||
try {
|
||||
requireBilling();
|
||||
const user = await getUser(req);
|
||||
if (typeof req.body.planId !== "string" || (req.body.target != null && typeof req.body.target !== "string")) { res.sendStatus(400); return; }
|
||||
let returnPath: string | undefined;
|
||||
if (req.body.draftId != null) {
|
||||
const draft = await getBillingDraft(user.id, req.body.draftId);
|
||||
returnPath = `${draft.path}?billingDraft=${draft._id}`;
|
||||
}
|
||||
res.json({ url: await createCheckout(user.id, req.body.planId, req.body.target, undefined, returnPath) });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
router.post("/portal", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
const account = await BillingAccount.findById(user.id).lean();
|
||||
if (!account?.customerId) { res.status(404).json({ error: "billing_customer_not_found" }); return; }
|
||||
const portal = await stripeClient().billingPortal.sessions.create({ customer: account.customerId, return_url: `${billingOrigin()}/profile?billing=portal` });
|
||||
res.json({ url: portal.url });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
router.post("/refresh", async (req, res) => {
|
||||
try {
|
||||
requireBilling();
|
||||
const user = await getUser(req);
|
||||
const account = await BillingAccount.findById(user.id).lean();
|
||||
let pending = !!account?.checkout?.key && !account.checkout.id;
|
||||
if (account?.checkout?.id) {
|
||||
await fulfillCheckout(account.checkout.id);
|
||||
const checkout = await stripeClient().checkout.sessions.retrieve(account.checkout.id);
|
||||
pending = checkout.status !== "expired" && checkout.payment_status === "unpaid";
|
||||
}
|
||||
const grants = await BillingGrant.find({ owner: user.id, subscriptionId: { $exists: true } }).lean();
|
||||
for (const grant of grants) await syncSubscription(grant.subscriptionId!);
|
||||
res.json({ ok: true, pending });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
export default router;
|
||||
@@ -1,3 +1,5 @@
|
||||
import config from "../../config";
|
||||
import { billingError } from "../../core/billing/config";
|
||||
import * as express from "express";
|
||||
import AnonymousError from "../../core/AnonymousError";
|
||||
import Conference from "../../core/Conference";
|
||||
@@ -12,37 +14,14 @@ const router = express.Router();
|
||||
router.use(ensureAuthenticated);
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: "free_conference",
|
||||
name: "Free",
|
||||
pricePerRepo: 0,
|
||||
storagePerRepo: -1,
|
||||
description: `<li><strong>Quota is deducted from user account</strong></li>
|
||||
<li>No-download</li>
|
||||
<li>Conference dashboard</li>`,
|
||||
},
|
||||
{
|
||||
id: "premium_conference",
|
||||
name: "Premium",
|
||||
pricePerRepo: 0.5,
|
||||
storagePerRepo: 500 * 8 * 1024,
|
||||
description: `<li>500Mo / repository</li>
|
||||
<li>Repository download</li>
|
||||
<li>Conference dashboard</li>`,
|
||||
},
|
||||
{
|
||||
id: "unlimited_conference",
|
||||
name: "Unlimited",
|
||||
pricePerRepo: 3,
|
||||
storagePerRepo: 0,
|
||||
description: `<li><strong>Unlimited</strong> repository size</li>
|
||||
<li>Repository download</li>
|
||||
<li>Conference dashboard</li>`,
|
||||
},
|
||||
{ id: "free_conference", name: "Free", pricePerRepo: 0, storagePerRepo: -1,
|
||||
description: "<li>Uses each author's allowance</li><li>Conference dashboard</li>" },
|
||||
{ id: "conference", name: "Review package", pricePerRepo: 0, storagePerRepo: config.PAID_CONFERENCE_STORAGE,
|
||||
description: `<li>Up to ${config.PAID_CONFERENCE_REPOSITORIES} repositories</li><li>Up to 180 days</li><li>Prepaid at checkout</li>` },
|
||||
];
|
||||
|
||||
router.get("/plans", async (req: express.Request, res: express.Response) => {
|
||||
res.json(plans);
|
||||
res.json(config.BILLING_ENABLED && config.STRIPE_CONFERENCE_PRICE ? plans : plans.filter(p => p.id === "free_conference"));
|
||||
});
|
||||
|
||||
router.get("/", async (req: express.Request, res: express.Response) => {
|
||||
@@ -99,11 +78,13 @@ function validateConferenceForm(conf: any) {
|
||||
object: conf,
|
||||
httpStatus: 400,
|
||||
});
|
||||
if (plans.filter((p) => p.id == conf.plan.planID).length != 1)
|
||||
if (!conf.plan || plans.filter((p) => p.id == conf.plan.planID).length != 1)
|
||||
throw new AnonymousError("invalid_plan", {
|
||||
object: conf,
|
||||
httpStatus: 400,
|
||||
});
|
||||
if (!Number.isFinite(new Date(conf.startDate).getTime()) || !Number.isFinite(new Date(conf.endDate).getTime())) throw billingError("conf_start_date_invalid");
|
||||
if (conf.plan.planID === "conference" && (!config.BILLING_ENABLED || !config.STRIPE_CONFERENCE_PRICE || new Date(conf.endDate).getTime() - new Date(conf.startDate).getTime() > 180 * 86400_000)) throw billingError("billing_invalid_review_window");
|
||||
const plan = plans.filter((p) => p.id == conf.plan.planID)[0];
|
||||
if (plan.pricePerRepo > 0) {
|
||||
const billing = conf.billing;
|
||||
@@ -145,16 +126,16 @@ function validateConferenceForm(conf: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function applyConferenceForm(
|
||||
model: IConferenceDocument,
|
||||
body: any,
|
||||
body: { name: string; startDate: string | Date; endDate: string | Date; url: string;
|
||||
options: IConferenceDocument["options"]; plan?: { planID: string } },
|
||||
isNew: boolean
|
||||
) {
|
||||
model.name = body.name;
|
||||
model.startDate = new Date(body.startDate);
|
||||
model.endDate = new Date(body.endDate);
|
||||
model.status = "ready";
|
||||
if (isNew) model.status = body.plan?.planID === "conference" ? "pending_payment" : "ready";
|
||||
model.url = body.url;
|
||||
model.options = body.options;
|
||||
if (isNew) model.repositories = [];
|
||||
@@ -178,7 +159,13 @@ router.post(
|
||||
model = queryModel;
|
||||
isOwnerOrAdmin(model.owners, user);
|
||||
}
|
||||
validateConferenceForm(req.body);
|
||||
if (req.params.conferenceID) {
|
||||
if (req.body.plan?.planID !== model.plan.planID) throw billingError("billing_plan_change_not_supported");
|
||||
if (model.plan.planID === "conference" && (new Date(req.body.startDate).getTime() !== model.startDate.getTime() || new Date(req.body.endDate).getTime() !== model.endDate.getTime())) throw billingError("billing_review_window_locked");
|
||||
}
|
||||
// Historical conference plans remain editable without being offered for purchase.
|
||||
const legacyPlan = req.params.conferenceID && !plans.some(p => p.id === model.plan.planID);
|
||||
validateConferenceForm(legacyPlan ? { ...req.body, plan: { planID: "free_conference" } } : req.body);
|
||||
applyConferenceForm(model, req.body, !req.params.conferenceID);
|
||||
|
||||
if (!req.params.conferenceID) {
|
||||
@@ -194,7 +181,7 @@ router.post(
|
||||
size: plans.filter((p) => p.id == req.body.plan.planID)[0]
|
||||
.storagePerRepo,
|
||||
file: 0,
|
||||
repository: 0,
|
||||
repository: req.body.plan.planID === "conference" ? config.PAID_CONFERENCE_REPOSITORIES : 0,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { neverExpirationDeadline, validateBillingExpiration } from "../../core/billing/expiration";
|
||||
import config from "../../config";
|
||||
import { assertConferenceAdmission, reserveRepository } from "../../core/billing/quotas";
|
||||
import { randomUUID } from "crypto";
|
||||
import { githubQuotaKey } from "../../core/github-token-context";
|
||||
import { selectRepositoryAccess, boundAppToken, appError } from "../../core/github-app";
|
||||
@@ -506,6 +509,14 @@ router.post(
|
||||
const sourceChanged = reconnecting || hasRepositorySourceChanged(repo.model, repoUpdate);
|
||||
const previousAccessRevision = repo.model.githubAccess?.revision;
|
||||
|
||||
if (repo.model.billingManaged) validateBillingExpiration(repoUpdate.options);
|
||||
if (config.BILLING_ENABLED && repoUpdate.options.expirationMode === "never" &&
|
||||
(repo.model.options.expirationMode !== "never" || repo.model.billingNeverExpiresUntil)) {
|
||||
repo.model.billingNeverExpiresUntil = await neverExpirationDeadline(String(repo.model.owner),
|
||||
repo.model.options.expirationMode === "never" ? repo.model.billingNeverExpiresUntil : undefined);
|
||||
} else if (repoUpdate.options.expirationMode !== "never") {
|
||||
repo.model.billingNeverExpiresUntil = undefined;
|
||||
}
|
||||
updateRepoModel(repo.model, repoUpdate);
|
||||
const reactivating = shouldReactivateInactiveRepository(repo.model);
|
||||
|
||||
@@ -513,6 +524,9 @@ router.post(
|
||||
repo.model.anonymizeDate = new Date();
|
||||
}
|
||||
|
||||
await assertConferenceAdmission(String(repo.model.owner), repo.repoId, repoUpdate.conference);
|
||||
if (repo.model.billingManaged) await reserveRepository(String(repo.model.owner), repo.repoId, repo.model.size?.storage || 0, repoUpdate.conference);
|
||||
|
||||
if (sourceChanged) {
|
||||
const parsedRepository = gh(repoUpdate.fullName);
|
||||
if (!parsedRepository?.owner || !parsedRepository?.name) {
|
||||
@@ -691,6 +705,10 @@ router.post("/", async (req, res) => {
|
||||
repo.owner = user.id;
|
||||
repo.githubAccess = selectedAccess.binding;
|
||||
|
||||
validateBillingExpiration(repoUpdate.options);
|
||||
if (repoUpdate.options.expirationMode === "never") {
|
||||
repo.billingNeverExpiresUntil = await neverExpirationDeadline(user.id);
|
||||
}
|
||||
updateRepoModel(repo, repoUpdate);
|
||||
repo.source.type = "GitHubStream";
|
||||
repo.source.repositoryId = repository.model.id;
|
||||
@@ -698,6 +716,9 @@ router.post("/", async (req, res) => {
|
||||
|
||||
repo.conference = repoUpdate.conference;
|
||||
|
||||
await assertConferenceAdmission(user.id, repo.repoId, repo.conference);
|
||||
repo.billingManaged = config.BILLING_ENABLED;
|
||||
await reserveRepository(user.id, repo.repoId, 0, repo.conference);
|
||||
await repo.save();
|
||||
|
||||
if (repoUpdate.conference) {
|
||||
|
||||
@@ -32,6 +32,7 @@ router.get(
|
||||
}
|
||||
const repo = await getRepo(req, res);
|
||||
if (!repo) return;
|
||||
if (repo.model.billingManaged && repo.model.truncatedFolders?.length) throw new AnonymousError("repository_tree_incomplete", { httpStatus: 409 });
|
||||
|
||||
let user: User | undefined = undefined;
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { BillingQuota, BillingDraft } from "../../core/billing/models";
|
||||
import { cancelAccountBilling } from "../../core/billing/stripe";
|
||||
import { allowance } from "../../core/billing/quotas";
|
||||
import DashboardName from "../../core/model/dashboard-name";
|
||||
import { dashboardSummary } from "./dashboard-summary";
|
||||
import { revokeGrant } from "./github-app";
|
||||
@@ -112,18 +115,21 @@ router.get("/quota", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const limits = config.BILLING_ENABLED ? await allowance(user.id) : null;
|
||||
const reserved = limits ? await BillingQuota.findById(limits.scope).lean() : null;
|
||||
res.json({
|
||||
storage: {
|
||||
used: totalStorage,
|
||||
total: config.DEFAULT_QUOTA,
|
||||
unit: limits ? "bytes" : "legacy",
|
||||
used: reserved ? reserved.allocations.reduce((sum, entry) => sum + (entry.bytes || 0), 0) : totalStorage,
|
||||
total: limits?.storage ?? config.DEFAULT_QUOTA,
|
||||
},
|
||||
file: {
|
||||
used: totalFiles,
|
||||
total: 0,
|
||||
},
|
||||
repository: {
|
||||
used: ready.length,
|
||||
total: 20,
|
||||
used: reserved ? reserved.allocations.length : ready.length,
|
||||
total: limits?.repositories ?? 20,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -175,6 +181,8 @@ router.post("/default", async (req, res) => {
|
||||
router.delete("/", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
if (config.BILLING_ENABLED) await cancelAccountBilling(user.id);
|
||||
if (isConnected) await BillingDraft.deleteMany({ owner: user.id });
|
||||
|
||||
const repositories = (await user.getRepositories()).filter(
|
||||
(r) => r.owner.id === user.model.id && r.status !== "removed"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reconcileNeverExpiration } from "../core/billing/expiration";
|
||||
import { Model, Document } from "mongoose";
|
||||
import * as schedule from "node-schedule";
|
||||
import AnonymizedGistModel from "../core/model/anonymizedGists/anonymizedGists.model";
|
||||
@@ -62,6 +63,7 @@ export function repositoryMaintenanceQuery(now: Date) {
|
||||
}
|
||||
|
||||
export async function runRepositoryStatusCheck(now = new Date()) {
|
||||
await reconcileNeverExpiration(now);
|
||||
logger.info("checking repository status and unused repositories");
|
||||
const fourMonthAgo = new Date(now);
|
||||
fourMonthAgo.setMonth(fourMonthAgo.getMonth() - 4);
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
const { URL } = require("node:url");
|
||||
const { setTimeout } = require("node:timers");
|
||||
const { expect } = require("chai");
|
||||
require("ts-node/register/transpile-only");
|
||||
const mongoose = require("mongoose");
|
||||
const config = require("../src/config").default;
|
||||
const { BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft } = require("../src/core/billing/models");
|
||||
const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
|
||||
const Conference = require("../src/core/model/conference/conferences.model").default;
|
||||
const { reserveRepository, allowance, assertConferenceAdmission } = require("../src/core/billing/quotas");
|
||||
const { fulfillCheckout, syncSubscription, createCheckout, processBillingEvent } = require("../src/core/billing/stripe");
|
||||
const uri = process.env.BILLING_TEST_MONGODB_URI;
|
||||
const owner = "507f1f77bcf86cd799439011";
|
||||
const future = () => new Date(Date.now() + 86400000);
|
||||
async function rejects(promise, message) {
|
||||
try { await promise; throw new Error("Expected rejection"); } catch (error) { expect(error.message).to.equal(message); }
|
||||
}
|
||||
(uri ? describe : describe.skip)("billing with MongoDB", function () {
|
||||
this.timeout(15000);
|
||||
const original = { ...config };
|
||||
before(async () => {
|
||||
if (!/^billing_test_/.test(new URL(uri).pathname.slice(1))) throw new Error("Use a disposable billing_test_ database");
|
||||
await mongoose.connect(uri);
|
||||
await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.init()));
|
||||
});
|
||||
beforeEach(async () => {
|
||||
Object.assign(config, { BILLING_ENABLED: true, FREE_AUTHOR_REPOSITORIES: 3, FREE_AUTHOR_STORAGE: 100,
|
||||
PAID_AUTHOR_STORAGE: 1000, PAID_AUTHOR_REPOSITORIES: 20, STRIPE_AUTHOR_MONTHLY_PRICE: "price_monthly", STRIPE_AUTHOR_PASS_PRICE: "price_pass", STRIPE_CONFERENCE_PRICE: "price_conf" });
|
||||
await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.deleteMany({})));
|
||||
await BillingAccount.create({ _id: owner, customerId: "cus_owner" });
|
||||
});
|
||||
after(async () => { Object.assign(config, original); await mongoose.disconnect(); });
|
||||
it("atomically admits only three concurrent repositories", async () => {
|
||||
const results = await Promise.allSettled(Array.from({ length: 12 }, (_, i) => reserveRepository(owner, `repo-${i}`, 10)));
|
||||
expect(results.filter(r => r.status === "fulfilled")).to.have.length(3);
|
||||
expect((await BillingQuota.findById(`user:${owner}`)).allocations).to.have.length(3);
|
||||
});
|
||||
it("atomically caps storage and never shrinks a concurrent reservation", async () => {
|
||||
const results = await Promise.allSettled([reserveRepository(owner, "a", 60), reserveRepository(owner, "b", 60)]);
|
||||
expect(results.filter(r => r.status === "fulfilled")).to.have.length(1);
|
||||
const ledger = await BillingQuota.findById(`user:${owner}`);
|
||||
const id = ledger.allocations[0].repoId;
|
||||
await Promise.all([reserveRepository(owner, id, 90), reserveRepository(owner, id, 1)]);
|
||||
expect((await BillingQuota.findById(`user:${owner}`)).allocations[0].bytes).to.equal(90);
|
||||
});
|
||||
it("counts legacy repositories and releases removed repositories", async () => {
|
||||
await Repo.create(["one", "two", "three"].map(repoId => ({ repoId, owner, status: "ready", size: { storage: 10 } })));
|
||||
await rejects(reserveRepository(owner, "four", 1), "quota_exceeded");
|
||||
await Repo.updateOne({ repoId: "one" }, { $set: { status: "removed" } });
|
||||
await reserveRepository(owner, "four", 1);
|
||||
expect((await BillingQuota.findById(`user:${owner}`)).allocations.map(a => a.repoId)).not.to.include("one");
|
||||
});
|
||||
it("charges shared repositories only to their owner, regardless of coauthor subscriptions", async () => {
|
||||
const collaborator = "507f1f77bcf86cd799439012";
|
||||
await Repo.create(["one", "two", "three"].map(repoId => ({
|
||||
repoId, owner, status: "ready", size: { storage: 10 },
|
||||
coauthors: [{ username: "collaborator", githubId: "42" }],
|
||||
})));
|
||||
// A collaborator can create their own repository even when the owner is full.
|
||||
await reserveRepository(collaborator, "own-work", 10);
|
||||
expect((await BillingQuota.findById(`user:${collaborator}`)).allocations.map(a => a.repoId)).to.deep.equal(["own-work"]);
|
||||
await BillingGrant.create({ _id: "coauthor-sub", owner: collaborator,
|
||||
scope: `user:${collaborator}`, status: "active", until: future(), planId: "author_monthly" });
|
||||
expect((await allowance(owner, "one")).plan).to.equal("free");
|
||||
await rejects(reserveRepository(owner, "four", 1), "quota_exceeded");
|
||||
// Editing shared work at the owner's count cap is allowed if its bytes fit.
|
||||
await reserveRepository(owner, "one", 20);
|
||||
await rejects(reserveRepository(owner, "one", 90), "quota_exceeded");
|
||||
});
|
||||
it("uses a conference allowance before an author's subscription", async () => {
|
||||
await BillingGrant.create([{ _id: "sub", owner, scope: `user:${owner}`, status: "active", until: future(), planId: "author_monthly" },
|
||||
{ _id: "conf", owner, scope: "conference:CONF", status: "paid", until: future(), planId: "conference" }]);
|
||||
expect((await allowance(owner, "repo", "CONF")).scope).to.equal("conference:CONF");
|
||||
await BillingGrant.updateOne({ _id: "conf" }, { $set: { revoked: true } });
|
||||
expect((await allowance(owner, "repo", "CONF")).scope).to.equal(`user:${owner}`);
|
||||
});
|
||||
it("requires the owner's recurring subscription for never expiration", async () => {
|
||||
const { neverExpirationDeadline, NEVER_EXPIRATION_GRACE_MS } = require("../src/core/billing/expiration");
|
||||
await rejects(neverExpirationDeadline(owner), "subscription_required_for_never_expiration");
|
||||
await BillingGrant.create({ _id: "pass-only", owner, scope: "repo:repo", status: "paid", until: future(), planId: "author_pass" });
|
||||
await rejects(neverExpirationDeadline(owner), "subscription_required_for_never_expiration");
|
||||
const until = future();
|
||||
await BillingGrant.create({ _id: "author", owner, scope: `user:${owner}`, status: "active", until, planId: "author_annual" });
|
||||
expect((await neverExpirationDeadline(owner)).getTime()).to.equal(until.getTime() + NEVER_EXPIRATION_GRACE_MS);
|
||||
});
|
||||
it("renews never expiration, honors 90 days of grace and leaves legacy links alone", async () => {
|
||||
const { reconcileNeverExpiration, NEVER_EXPIRATION_GRACE_MS } = require("../src/core/billing/expiration");
|
||||
const until = future();
|
||||
const oldDeadline = new Date(Date.now() + 1000);
|
||||
await Repo.create([{ repoId: "paid-never", owner, status: "ready", options: { expirationMode: "never" }, billingNeverExpiresUntil: oldDeadline },
|
||||
{ repoId: "legacy-never", owner, status: "ready", options: { expirationMode: "never" } }]);
|
||||
await BillingGrant.create({ _id: "author", owner, scope: `user:${owner}`, status: "active", until, planId: "author_monthly" });
|
||||
await reconcileNeverExpiration();
|
||||
const deadline = new Date(until.getTime() + NEVER_EXPIRATION_GRACE_MS);
|
||||
expect((await Repo.findOne({ repoId: "paid-never" })).billingNeverExpiresUntil.getTime()).to.equal(deadline.getTime());
|
||||
await reconcileNeverExpiration(new Date(deadline.getTime() - 1));
|
||||
expect((await Repo.findOne({ repoId: "paid-never" })).options.expirationMode).to.equal("never");
|
||||
await reconcileNeverExpiration(deadline, async () => {});
|
||||
const expired = await Repo.findOne({ repoId: "paid-never" });
|
||||
expect(expired.options.expirationMode).to.equal("remove");
|
||||
expect(expired.options.expirationDate.getTime()).to.equal(deadline.getTime());
|
||||
expect((await Repo.findOne({ repoId: "legacy-never" })).options.expirationMode).to.equal("never");
|
||||
});
|
||||
function session(status = "paid") {
|
||||
return { id: "cs_pass", metadata: { application: "anonymous-github", owner, planId: "author_pass", target: "repo" },
|
||||
customer: "cus_owner", status: "complete", payment_status: status, created: Math.floor(Date.now() / 1000),
|
||||
line_items: { data: [{ price: { id: "price_pass" }, quantity: 1 }] }, payment_intent: { id: "pi_pass", latest_charge: { amount_refunded: 0, disputed: false } } };
|
||||
}
|
||||
it("ignores an unfinished recurring Checkout before Stripe assigns a subscription", async () => {
|
||||
const stripe = { checkout: { sessions: { retrieve: async () => ({
|
||||
id: "cs_open", metadata: { application: "anonymous-github", owner, planId: "author_monthly" },
|
||||
customer: "cus_owner", status: "open", payment_status: "unpaid", subscription: null,
|
||||
}) } } };
|
||||
await fulfillCheckout("cs_open", stripe);
|
||||
expect(await BillingGrant.countDocuments()).to.equal(0);
|
||||
});
|
||||
it("ignores unpaid checkout and fulfills duplicate deliveries exactly once", async () => {
|
||||
let value = session("unpaid");
|
||||
const stripe = { checkout: { sessions: { retrieve: async () => value } } };
|
||||
await fulfillCheckout("cs_pass", stripe);
|
||||
expect(await BillingGrant.countDocuments()).to.equal(0);
|
||||
value = session();
|
||||
await Promise.all([fulfillCheckout("cs_pass", stripe), fulfillCheckout("cs_pass", stripe)]);
|
||||
const grant = await BillingGrant.findById("cs_pass");
|
||||
expect(await BillingGrant.countDocuments()).to.equal(1);
|
||||
expect(grant.scope).to.equal("repo:repo");
|
||||
expect(grant.until.getTime()).to.equal(value.created * 1000 + 180 * 86400000);
|
||||
});
|
||||
it("does not reactivate a refunded pass on a checkout replay", async () => {
|
||||
const value = session();
|
||||
const stripe = { checkout: { sessions: { retrieve: async () => value } } };
|
||||
await fulfillCheckout("cs_pass", stripe);
|
||||
await processBillingEvent({ type: "charge.refunded", data: { object: { payment_intent: "pi_pass" } } }, { ...stripe, invoicePayments: { list: async () => ({ data: [] }) } });
|
||||
await fulfillCheckout("cs_pass", stripe);
|
||||
expect((await BillingGrant.findById("cs_pass")).revoked).to.equal(true);
|
||||
});
|
||||
it("rejects a checkout for a different Stripe customer", async () => {
|
||||
const value = session(); value.customer = "cus_other";
|
||||
await rejects(fulfillCheckout("cs_pass", { checkout: { sessions: { retrieve: async () => value } } }), "billing_customer_mismatch");
|
||||
});
|
||||
function subscription(overrides = {}) {
|
||||
return { id: "sub_owner", metadata: { application: "anonymous-github", owner }, customer: "cus_owner", status: "active",
|
||||
latest_invoice: { status: "paid" }, items: { data: [{ price: { id: "price_monthly" }, current_period_end: Math.floor(future().getTime() / 1000) }] }, cancel_at_period_end: false, ...overrides };
|
||||
}
|
||||
it("recognizes portal cancellations scheduled with cancel_at", async () => {
|
||||
await syncSubscription("sub_owner", { subscriptions: { retrieve: async () => subscription({
|
||||
cancel_at_period_end: false, cancel_at: Math.floor(future().getTime() / 1000),
|
||||
}) } });
|
||||
expect((await BillingGrant.findById("sub_owner")).cancelAtPeriodEnd).to.equal(true);
|
||||
expect((await allowance(owner)).plan).to.equal("author_monthly");
|
||||
});
|
||||
it("honors paid subscription periods, cancellation and renewal failure", async () => {
|
||||
let value = subscription();
|
||||
const stripe = { subscriptions: { retrieve: async () => value } };
|
||||
await syncSubscription("sub_owner", stripe);
|
||||
expect((await allowance(owner)).plan).to.equal("author_monthly");
|
||||
const paidThrough = (await BillingGrant.findById("sub_owner")).until;
|
||||
value = subscription({ status: "past_due", latest_invoice: { status: "open" } });
|
||||
value.items.data[0].current_period_end += 86400 * 30;
|
||||
await syncSubscription("sub_owner", stripe);
|
||||
expect((await BillingGrant.findById("sub_owner")).until).to.deep.equal(paidThrough);
|
||||
value = subscription({ cancel_at_period_end: true });
|
||||
await syncSubscription("sub_owner", stripe);
|
||||
expect((await allowance(owner)).plan).to.equal("author_monthly");
|
||||
value = subscription({ status: "canceled" });
|
||||
await syncSubscription("sub_owner", stripe);
|
||||
expect((await allowance(owner)).plan).to.equal("free");
|
||||
});
|
||||
it("protects a newer subscription update from an older in-flight fetch", async () => {
|
||||
await BillingGrant.create({ _id: "sub_owner", owner, scope: `user:${owner}`, planId: "author_monthly", status: "active", until: future() });
|
||||
let release; const gate = new Promise(resolve => { release = resolve; }); let started;
|
||||
const firstRead = new Promise(resolve => { started = resolve; }); let calls = 0;
|
||||
const stripe = { subscriptions: { retrieve: async () => {
|
||||
if (++calls === 1) { started(); await gate; return subscription(); }
|
||||
return subscription({ status: "canceled" });
|
||||
} } };
|
||||
const old = syncSubscription("sub_owner", stripe);
|
||||
await firstRead;
|
||||
await syncSubscription("sub_owner", stripe);
|
||||
release(); await old;
|
||||
expect((await BillingGrant.findById("sub_owner")).status).to.equal("canceled");
|
||||
});
|
||||
it("keeps conference admission closed until verified payment", async () => {
|
||||
await Conference.create({ conferenceID: "CONF", owners: [owner], status: "pending_payment", startDate: new Date(Date.now() - 1000), endDate: future(), plan: { planID: "conference" } });
|
||||
await rejects(assertConferenceAdmission(owner, "repo", "CONF"), "conf_not_activated");
|
||||
const value = session(); value.id = "cs_conf"; value.metadata.planId = "conference"; value.metadata.target = "CONF"; value.line_items.data[0].price.id = "price_conf";
|
||||
await fulfillCheckout("cs_conf", { checkout: { sessions: { retrieve: async () => value } } });
|
||||
expect((await Conference.findOne({ conferenceID: "CONF" })).status).to.equal("ready");
|
||||
await assertConferenceAdmission(owner, "repo", "CONF");
|
||||
await BillingGrant.updateOne({ _id: "cs_conf" }, { $set: { revoked: true } });
|
||||
await rejects(assertConferenceAdmission(owner, "repo", "CONF"), "conference_payment_required");
|
||||
});
|
||||
it("prevents two simultaneous checkout sessions and reuses an open session", async () => {
|
||||
let creates = 0;
|
||||
const stripe = { prices: { retrieve: async () => ({ active: true, currency: "eur", unit_amount: 500, recurring: { interval: "month", interval_count: 1 } }) }, subscriptions: { list: async () => ({ data: [] }) }, checkout: { sessions: {
|
||||
create: async () => { creates++; await new Promise(r => setTimeout(r, 30)); return { id: "cs_new", url: "https://checkout.stripe.com/test" }; },
|
||||
retrieve: async () => ({ id: "cs_new", status: "open", url: "https://checkout.stripe.com/test" }),
|
||||
} } };
|
||||
const results = await Promise.allSettled([createCheckout(owner, "author_monthly", "", stripe), createCheckout(owner, "author_monthly", "", stripe)]);
|
||||
expect(results.filter(r => r.status === "fulfilled")).to.have.length(1);
|
||||
await createCheckout(owner, "author_monthly", "", stripe);
|
||||
expect(creates).to.equal(1);
|
||||
});
|
||||
it("revokes only the refunded subscription, including refunds arriving before subscription sync", async () => {
|
||||
await BillingGrant.create({ _id: "other-sub", owner, customerId: "cus_owner", subscriptionId: "other-sub", status: "active", until: future() });
|
||||
const stripe = { invoicePayments: { list: async () => ({ data: [{ invoice: "in_refunded" }] }) },
|
||||
invoices: { retrieve: async () => ({ parent: { subscription_details: { subscription: "sub_refunded" } } }) } };
|
||||
await processBillingEvent({ type: "charge.refunded", data: { object: { customer: "cus_owner", payment_intent: "pi_refunded" } } }, stripe);
|
||||
expect((await BillingGrant.findById("sub_refunded")).revoked).to.equal(true);
|
||||
expect((await BillingGrant.findById("other-sub")).revoked).to.equal(false);
|
||||
await syncSubscription("sub_refunded", { subscriptions: { retrieve: async () => subscription({ id: "sub_refunded" }) } });
|
||||
expect((await BillingGrant.findById("sub_refunded")).revoked).to.equal(true);
|
||||
});
|
||||
|
||||
it("keeps reviewer visits from refreshing snapshots after paid access expires", async () => {
|
||||
const Repository = require("../src/core/Repository").default;
|
||||
const model = new Repo({ repoId: "frozen", owner, billingManaged: true, status: "ready", lastView: new Date(0), options: { update: true, expirationMode: "never" }, source: { type: "GitHubStream", repositoryName: "owner/repo" } });
|
||||
const repo = new Repository(model);
|
||||
repo.getToken = () => { throw new Error("Unexpected upstream refresh"); };
|
||||
await BillingGrant.create({ _id: "expired-sub", owner, scope: `user:${owner}`, status: "active", until: new Date(0), planId: "author_monthly" });
|
||||
await repo.updateIfNeeded();
|
||||
expect(repo.status).to.equal("ready");
|
||||
});
|
||||
|
||||
it("authenticates billing, checks CSRF, and verifies raw webhook signatures", async () => {
|
||||
const express = require("express");
|
||||
const sessionMiddleware = require("express-session");
|
||||
const Stripe = require("stripe");
|
||||
const Users = require("../src/core/model/users/users.model").default;
|
||||
const routes = require("../src/server/routes/billing");
|
||||
await Users.updateOne({ _id: owner }, { $set: { username: "billing-test-author", status: "active" } }, { upsert: true });
|
||||
Object.assign(config, { STRIPE_SECRET_KEY: "sk_test_example", STRIPE_WEBHOOK_SECRET: "whsec_test_example", STRIPE_AUTHOR_MONTHLY_PRICE: "", STRIPE_AUTHOR_ANNUAL_PRICE: "", STRIPE_AUTHOR_PASS_PRICE: "", STRIPE_CONFERENCE_PRICE: "" });
|
||||
const app = express();
|
||||
app.use("/webhook", routes.billingWebhook);
|
||||
app.use(express.json());
|
||||
app.use(sessionMiddleware({ secret: "billing-test-session-secret", resave: false, saveUninitialized: false }));
|
||||
app.use((req, _res, next) => { req.isAuthenticated = () => req.headers["x-test-auth"] === "yes"; req.user = { user: { _id: owner } }; next(); });
|
||||
app.use("/billing", routes.default);
|
||||
const server = await new Promise(resolve => { const listening = app.listen(0, "127.0.0.1", () => resolve(listening)); });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
expect((await globalThis.fetch(`${base}/billing`)).status).to.equal(401);
|
||||
const get = await globalThis.fetch(`${base}/billing`, { headers: { "x-test-auth": "yes" } });
|
||||
const data = await get.json();
|
||||
expect(data.csrf).to.be.a("string");
|
||||
expect(JSON.stringify(data)).not.to.include("cus_owner");
|
||||
const headers = { "x-test-auth": "yes", cookie: get.headers.get("set-cookie").split(";")[0], "content-type": "application/json" };
|
||||
expect((await globalThis.fetch(`${base}/billing/checkout`, { method: "POST", headers, body: JSON.stringify({ planId: "arbitrary" }) })).status).to.equal(403);
|
||||
expect((await globalThis.fetch(`${base}/billing/draft`, { method: "POST", headers, body: JSON.stringify({ path: "/anonymize", values: { terms: "Private Author" } }) })).status).to.equal(403);
|
||||
headers["x-csrf-token"] = data.csrf;
|
||||
const saved = await globalThis.fetch(`${base}/billing/draft`, { method: "POST", headers, body: JSON.stringify({ path: "/anonymize", values: { terms: "Private Author" } }) });
|
||||
expect(saved.status).to.equal(200);
|
||||
const draft = await saved.json();
|
||||
expect((await globalThis.fetch(`${base}/billing/draft/${draft.id}`)).status).to.equal(401);
|
||||
const restored = await globalThis.fetch(`${base}/billing/draft/${draft.id}`, { headers });
|
||||
expect(restored.headers.get("cache-control")).to.equal("no-store");
|
||||
expect((await restored.json()).values.terms).to.equal("Private Author");
|
||||
await BillingDraft.updateOne({ _id: draft.id }, { owner: "another-owner" });
|
||||
expect((await globalThis.fetch(`${base}/billing/draft/${draft.id}`, { headers })).status).to.equal(404);
|
||||
|
||||
const rejected = await globalThis.fetch(`${base}/billing/checkout`, { method: "POST", headers, body: JSON.stringify({ planId: "arbitrary" }) });
|
||||
expect((await rejected.json()).error).to.equal("billing_plan_unavailable");
|
||||
const payload = JSON.stringify({ id: "evt_test", type: "customer.created", data: { object: { id: "cus_unused" } } });
|
||||
const stripe = new Stripe("sk_test_example");
|
||||
const signature = stripe.webhooks.generateTestHeaderString({ payload, secret: config.STRIPE_WEBHOOK_SECRET });
|
||||
expect((await globalThis.fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": signature }, body: payload })).status).to.equal(200);
|
||||
expect((await globalThis.fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": signature }, body: payload + " " })).status).to.equal(400);
|
||||
expect((await BillingEvent.findById("test:platform:evt_test")).state).to.equal("pending");
|
||||
const originalUpdate = BillingEvent.updateOne;
|
||||
BillingEvent.updateOne = async () => { throw new Error("simulated database outage"); };
|
||||
try {
|
||||
expect((await globalThis.fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": signature }, body: payload })).status).to.equal(500);
|
||||
} finally { BillingEvent.updateOne = originalUpdate; }
|
||||
} finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await Users.deleteOne({ _id: owner }); }
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
const { URL } = require("url");
|
||||
const { expect } = require("chai");
|
||||
require("ts-node/register/transpile-only");
|
||||
const mongoose = require("mongoose");
|
||||
const config = require("../src/config").default;
|
||||
const { BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, BillingDraft } = require("../src/core/billing/models");
|
||||
const { receiveBillingEvent, processBillingInbox, billingHealth } = require("../src/core/billing/events");
|
||||
const { reconcileBillingAccount } = require("../src/core/billing/reconciliation");
|
||||
const { createCheckout, processBillingEvent } = require("../src/core/billing/stripe");
|
||||
const { reconcileNeverExpiration, NEVER_EXPIRATION_GRACE_MS } = require("../src/core/billing/expiration");
|
||||
const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
|
||||
const uri = process.env.BILLING_TEST_MONGODB_URI;
|
||||
const owner = "507f1f77bcf86cd799439011";
|
||||
const { saveBillingDraft, getBillingDraft } = require("../src/core/billing/drafts");
|
||||
const models = [BillingDraft, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, Repo];
|
||||
const iterable = values => ({ async *[Symbol.asyncIterator]() { yield* values; } });
|
||||
const event = id => ({ id, livemode: false, type: "customer.created", data: { object: { id: "cus_test", email: "private@example.org" } } });
|
||||
(uri ? describe : describe.skip)("billing failure recovery", function () {
|
||||
this.timeout(20000);
|
||||
const original = { ...config };
|
||||
before(async () => {
|
||||
if (!/^billing_test_/.test(new URL(uri).pathname.slice(1))) throw new Error("Disposable database required");
|
||||
await mongoose.connect(uri);
|
||||
await Promise.all(models.map(m => m.init()));
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await Promise.all(models.map(m => m.deleteMany({})));
|
||||
Object.assign(config, { BILLING_ENABLED: true, BILLING_CHECKOUT_ENABLED: true, STRIPE_AUTHOR_MONTHLY_PRICE: "price_monthly" });
|
||||
await BillingAccount.create({ _id: owner, customerId: "cus_owner" });
|
||||
});
|
||||
after(async () => { Object.assign(config, original); await mongoose.disconnect(); });
|
||||
it("deduplicates concurrent receipt without retaining customer data", async () => {
|
||||
await Promise.all(Array.from({ length: 8 }, () => receiveBillingEvent(event("evt_one"))));
|
||||
expect(await BillingEvent.countDocuments()).to.equal(1);
|
||||
expect(JSON.stringify(await BillingEvent.findOne())).not.to.include("private@example.org");
|
||||
let calls = 0;
|
||||
await Promise.all([processBillingInbox(1, async () => { calls++; }), processBillingInbox(1, async () => { calls++; })]);
|
||||
expect(calls).to.equal(1);
|
||||
expect((await BillingEvent.findOne()).state).to.equal("complete");
|
||||
});
|
||||
it("recovers a crash after side effects but before acknowledgement", async () => {
|
||||
await receiveBillingEvent(event("evt_crash"));
|
||||
await BillingGrant.create({ _id: "already-applied", status: "paid" });
|
||||
await BillingEvent.updateOne({}, { $set: { state: "processing", token: "dead-worker", leaseUntil: new Date(0) } });
|
||||
await processBillingInbox(1, async () => {
|
||||
await BillingGrant.updateOne({ _id: "already-applied" }, { $setOnInsert: { status: "paid" } }, { upsert: true });
|
||||
});
|
||||
expect(await BillingGrant.countDocuments()).to.equal(1);
|
||||
expect((await BillingEvent.findOne()).state).to.equal("complete");
|
||||
});
|
||||
it("backs off failures, redacts errors and exposes exhausted retries", async () => {
|
||||
await receiveBillingEvent(event("evt_fail"));
|
||||
await processBillingInbox(1, async () => { throw new Error("secret payload"); });
|
||||
let record = await BillingEvent.findOne();
|
||||
expect(record.state).to.equal("pending");
|
||||
expect(record.nextAttemptAt.getTime()).to.be.greaterThan(record.receivedAt.getTime());
|
||||
expect(record.lastError).not.to.include("secret");
|
||||
await BillingEvent.updateOne({}, { $set: { attempts: 11, nextAttemptAt: new Date(0) } });
|
||||
await processBillingInbox(1, async () => { throw new Error("secret"); });
|
||||
record = await BillingEvent.findOne();
|
||||
expect(record.state).to.equal("failed");
|
||||
expect((await billingHealth()).failed).to.equal(1);
|
||||
});
|
||||
it("recovers missing grants from provider history and applies missed refunds", async () => {
|
||||
const sub = { id: "sub_lost", metadata: { application: "anonymous-github", owner }, customer: "cus_owner", status: "active",
|
||||
latest_invoice: { status: "paid" }, items: { data: [{ price: { id: "price_monthly" }, current_period_end: Math.floor(Date.now() / 1000) + 86400 }] } };
|
||||
const stripe = { subscriptions: { list: () => iterable([sub]), retrieve: async () => sub },
|
||||
checkout: { sessions: { list: () => iterable([]) } },
|
||||
charges: { list: () => iterable([{ id: "ch_refunded", refunded: true, payment_intent: "pi_refunded" }]) },
|
||||
invoicePayments: { list: async () => ({ data: [{ invoice: "in_refunded" }] }) },
|
||||
invoices: { retrieve: async () => ({ parent: { subscription_details: { subscription: "sub_lost" } } }) } };
|
||||
await reconcileBillingAccount(owner, stripe);
|
||||
expect((await BillingGrant.findById("sub_lost")).revoked).to.equal(true);
|
||||
expect((await BillingAccount.findById(owner)).reconciledAt).to.be.instanceOf(Date);
|
||||
// A restored database with no grants can reconstruct the same final state.
|
||||
await BillingGrant.deleteMany({});
|
||||
await reconcileBillingAccount(owner, stripe);
|
||||
expect((await BillingGrant.findById("sub_lost")).revoked).to.equal(true);
|
||||
});
|
||||
it("recovers paid time when the latest invoice failed after a missed renewal", async () => {
|
||||
const { syncSubscription } = require("../src/core/billing/stripe");
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
await syncSubscription("sub_history", {
|
||||
subscriptions: { retrieve: async () => ({ id: "sub_history", customer: "cus_owner", metadata: { application: "anonymous-github", owner },
|
||||
status: "past_due", latest_invoice: { status: "open" }, items: { data: [{ id: "si_owned", price: { id: "price_monthly" }, current_period_end: end + 86400 }] } }) },
|
||||
invoices: { list: () => iterable([{ id: "in_paid" }]), listLineItems: () => iterable([
|
||||
{ amount: 500, parent: { subscription_item_details: { subscription_item: "si_owned" } }, period: { end } },
|
||||
{ amount: 500, parent: { subscription_item_details: { subscription_item: "si_unrelated" } }, period: { end: end + 999999 } },
|
||||
]) },
|
||||
});
|
||||
expect((await BillingGrant.findById("sub_history")).until.getTime()).to.equal(end * 1000);
|
||||
});
|
||||
it("preserves a refund received before the first grant exists", async () => {
|
||||
const { fulfillCheckout } = require("../src/core/billing/stripe");
|
||||
config.STRIPE_AUTHOR_PASS_PRICE = "price_pass";
|
||||
const stripe = { invoicePayments: { list: async () => ({ data: [] }) }, checkout: { sessions: { retrieve: async () => ({
|
||||
id: "cs_late", status: "complete", payment_status: "paid", customer: "cus_owner", created: Math.floor(Date.now() / 1000),
|
||||
metadata: { application: "anonymous-github", owner, planId: "author_pass", target: "repo" },
|
||||
line_items: { data: [{ price: { id: "price_pass" }, quantity: 1 }] },
|
||||
payment_intent: { id: "pi_early", latest_charge: { refunded: false, disputed: false } },
|
||||
}) } } };
|
||||
await processBillingEvent({ type: "charge.refunded", data: { object: { payment_intent: "pi_early", refunded: true } } }, stripe);
|
||||
await fulfillCheckout("cs_late", stripe);
|
||||
expect((await BillingGrant.findById("cs_late")).revoked).to.equal(true);
|
||||
});
|
||||
it("starts a fresh attempt only after the old immutable request cannot execute", async () => {
|
||||
await BillingAccount.updateOne({ _id: owner }, { $set: { checkout: {
|
||||
key: "old", planId: "author_monthly", target: "", createdAt: new Date(0), params: { expires_at: 1000 },
|
||||
} } });
|
||||
const stripe = checkoutStripe();
|
||||
stripe.checkout.sessions.create = async (_params, options) => {
|
||||
expect(options.idempotencyKey).not.to.equal("old");
|
||||
return { id: "cs_fresh", url: "https://checkout.stripe.com/fresh" };
|
||||
};
|
||||
expect(await createCheckout(owner, "author_monthly", "", stripe)).to.equal("https://checkout.stripe.com/fresh");
|
||||
});
|
||||
it("keeps partial refunds active and revokes a full refund", async () => {
|
||||
await BillingGrant.create({ _id: "pass", paymentIntentId: "pi_pass", status: "paid" });
|
||||
const stripe = { invoicePayments: { list: async () => ({ data: [] }) } };
|
||||
const refund = refunded => ({ type: "charge.refunded", data: { object: { payment_intent: "pi_pass", refunded } } });
|
||||
await processBillingEvent(refund(false), stripe);
|
||||
expect((await BillingGrant.findById("pass")).revoked).to.equal(false);
|
||||
await processBillingEvent(refund(true), stripe);
|
||||
expect((await BillingGrant.findById("pass")).revocationReason).to.equal("full_refund");
|
||||
});
|
||||
it("requires review for a new dispute but ignores an older refund replay after review", async () => {
|
||||
await BillingGrant.create({ _id: "reviewed-pass", paymentIntentId: "pi_reviewed", status: "paid" });
|
||||
const stripe = { invoicePayments: { list: async () => ({ data: [] }) }, charges: { retrieve: async () => ({ payment_intent: "pi_reviewed", disputed: true }) } };
|
||||
const refund = { type: "charge.refunded", data: { object: { payment_intent: "pi_reviewed", refunded: true } } };
|
||||
await processBillingEvent(refund, stripe);
|
||||
await BillingRevocation.updateOne({ _id: "pi_reviewed" }, { $set: { reviewed: true } });
|
||||
await BillingGrant.updateOne({ _id: "reviewed-pass" }, { $set: { revoked: false } });
|
||||
await processBillingEvent({ type: "charge.dispute.created", data: { object: { charge: "ch_reviewed" } } }, stripe);
|
||||
expect((await BillingGrant.findById("reviewed-pass")).revoked).to.equal(true);
|
||||
await BillingRevocation.updateOne({ _id: "pi_reviewed" }, { $set: { reviewed: true } });
|
||||
await BillingGrant.updateOne({ _id: "reviewed-pass" }, { $set: { revoked: false } });
|
||||
await processBillingEvent(refund, stripe);
|
||||
expect((await BillingGrant.findById("reviewed-pass")).revoked).to.equal(false);
|
||||
});
|
||||
it("defers expiration during outage and discovers a renewal before removing content", async () => {
|
||||
const deadline = new Date(Date.now() - 1000);
|
||||
await Repo.create({ repoId: "protected", owner, status: "ready", options: { expirationMode: "never" }, billingNeverExpiresUntil: deadline });
|
||||
await reconcileNeverExpiration(new Date(), async () => { throw new Error("Stripe unavailable"); });
|
||||
expect((await Repo.findOne()).options.expirationMode).to.equal("never");
|
||||
const until = new Date(Date.now() + 86400_000);
|
||||
await reconcileNeverExpiration(new Date(), async () => {
|
||||
await BillingGrant.create({ _id: "renewal", owner, scope: `user:${owner}`, planId: "author_monthly", until, status: "active" });
|
||||
});
|
||||
const repo = await Repo.findOne();
|
||||
expect(repo.options.expirationMode).to.equal("never");
|
||||
expect(repo.billingNeverExpiresUntil.getTime()).to.equal(until.getTime() + NEVER_EXPIRATION_GRACE_MS);
|
||||
});
|
||||
function checkoutStripe() {
|
||||
return { prices: { retrieve: async () => ({ active: true, currency: "eur", unit_amount: 500, recurring: { interval: "month", interval_count: 1 } }) },
|
||||
subscriptions: { list: async () => ({ data: [] }) }, checkout: { sessions: {
|
||||
create: async () => { throw new Error("connection lost"); },
|
||||
list: () => iterable([]),
|
||||
retrieve: async () => ({ status: "open", url: "https://checkout.stripe.com/recovered" }),
|
||||
} } };
|
||||
}
|
||||
it("keeps checkout drafts private, bounded and expired even before the TTL sweep", async () => {
|
||||
const draft = await saveBillingDraft(owner, "/anonymize", { terms: "Private Author", options: { expirationMode: "never" }, customerId: "untrusted" });
|
||||
expect((await getBillingDraft(owner, draft.id)).values).not.to.have.property("customerId");
|
||||
for (const other of ["someone-else", owner]) {
|
||||
if (other === owner) await BillingDraft.updateOne({ _id: draft.id }, { expiresAt: new Date(0) });
|
||||
try { await getBillingDraft(other, draft.id); throw new Error("expected rejection"); }
|
||||
catch (error) { expect(error.message).to.equal("billing_draft_not_found"); }
|
||||
}
|
||||
for (const path of ["//evil.example", "/anonymize?next=https://evil.example", "/anonymize/../profile"]) {
|
||||
try { await saveBillingDraft(owner, path, {}); throw new Error("expected rejection"); }
|
||||
catch (error) { expect(error.message).to.equal("billing_invalid_draft"); }
|
||||
}
|
||||
try { await saveBillingDraft(owner, "/anonymize", { terms: "x".repeat(65537) }); throw new Error("expected rejection"); }
|
||||
catch (error) { expect(error.message).to.equal("billing_invalid_draft"); }
|
||||
});
|
||||
it("returns checkout to the saved task and refuses reuse for a different task", async () => {
|
||||
const stripe = checkoutStripe();
|
||||
const draft = await saveBillingDraft(owner, "/anonymize", { terms: "Author" });
|
||||
const destination = `/anonymize?billingDraft=${draft.id}`;
|
||||
let parameters;
|
||||
stripe.checkout.sessions.create = async params => { parameters = params; return { id: "cs_task", url: "https://checkout.stripe.com/task" }; };
|
||||
await createCheckout(owner, "author_monthly", "", stripe, destination);
|
||||
expect(parameters.success_url).to.include(destination + "&billing=success");
|
||||
expect(parameters.cancel_url).to.include(destination + "&billing=cancelled");
|
||||
await createCheckout(owner, "author_monthly", "", stripe, destination);
|
||||
try { await createCheckout(owner, "author_monthly", "", stripe); throw new Error("expected rejection"); }
|
||||
catch (error) { expect(error.message).to.equal("billing_checkout_in_progress"); }
|
||||
try { await createCheckout(owner, "author_monthly", "", stripe, "https://evil.example"); throw new Error("expected rejection"); }
|
||||
catch (error) { expect(error.message).to.equal("billing_invalid_draft"); }
|
||||
});
|
||||
it("retries a lost response with identical parameters and the same key", async () => {
|
||||
const stripe = checkoutStripe();
|
||||
let first;
|
||||
stripe.checkout.sessions.create = async (params, options) => { first = { params, options }; throw new Error("connection lost"); };
|
||||
try { await createCheckout(owner, "author_monthly", "", stripe); } catch (e) { expect(e.message).to.equal("connection lost"); }
|
||||
const saved = await BillingAccount.findById(owner);
|
||||
expect(saved.checkout.id).to.equal(undefined);
|
||||
stripe.checkout.sessions.create = async (params, options) => {
|
||||
expect(params).to.deep.equal(first.params);
|
||||
expect(options).to.deep.equal(first.options);
|
||||
return { id: "cs_recovered", url: "https://checkout.stripe.com/recovered" };
|
||||
};
|
||||
await createCheckout(owner, "author_monthly", "", stripe);
|
||||
expect((await BillingAccount.findById(owner)).checkout.id).to.equal("cs_recovered");
|
||||
});
|
||||
it("recovers an old attempt by metadata without creating another payment", async () => {
|
||||
await BillingAccount.updateOne({ _id: owner }, { $set: { checkout: { key: "attempt", planId: "author_monthly", target: "", createdAt: new Date(0) } } });
|
||||
const stripe = checkoutStripe();
|
||||
stripe.checkout.sessions.list = () => iterable([{ id: "cs_recovered", metadata: { attempt: "attempt", owner } }]);
|
||||
expect(await createCheckout(owner, "author_monthly", "", stripe)).to.equal("https://checkout.stripe.com/recovered");
|
||||
});
|
||||
it("blocks unresolved old requests instead of reusing a pruned key", async () => {
|
||||
await BillingAccount.updateOne({ _id: owner }, { $set: { checkout: { key: "old", planId: "author_monthly", target: "", createdAt: new Date(0) } } });
|
||||
try { await createCheckout(owner, "author_monthly", "", checkoutStripe()); throw new Error("expected rejection"); }
|
||||
catch (e) { expect(e.message).to.equal("billing_recovery_required"); }
|
||||
});
|
||||
it("checks subsequent subscription pages before offering another subscription", async () => {
|
||||
const stripe = checkoutStripe();
|
||||
stripe.subscriptions.list = async ({ starting_after }) => starting_after
|
||||
? { data: [{ id: "active", status: "active" }], has_more: false }
|
||||
: { data: [{ id: "old", status: "canceled" }], has_more: true };
|
||||
try { await createCheckout(owner, "author_monthly", "", stripe); throw new Error("expected rejection"); }
|
||||
catch (e) { expect(e.message).to.equal("billing_subscription_exists"); }
|
||||
});
|
||||
it("stops new purchases independently of event processing", async () => {
|
||||
config.BILLING_CHECKOUT_ENABLED = false;
|
||||
try { await createCheckout(owner, "author_monthly", "", checkoutStripe()); throw new Error("expected rejection"); }
|
||||
catch (e) { expect(e.message).to.equal("billing_checkout_disabled"); }
|
||||
await receiveBillingEvent(event("evt_existing"));
|
||||
await processBillingInbox(1, async () => {});
|
||||
expect((await BillingEvent.findOne()).state).to.equal("complete");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const { expect } = require("chai");
|
||||
require("ts-node/register/transpile-only");
|
||||
const { usableGrant } = require("../src/core/billing/quotas");
|
||||
const { billingOrigin } = require("../src/core/billing/config");
|
||||
const config = require("../src/config").default;
|
||||
const { applyConferenceForm } = require("../src/server/routes/conference");
|
||||
|
||||
describe("paid access policy", () => {
|
||||
it("requires paid time and rejects revoked, unpaid, cancelled and expired grants", () => {
|
||||
const until = new Date(Date.now() + 60000);
|
||||
for (const status of ["active", "paid", "past_due"]) expect(usableGrant({ status, until })).to.equal(true);
|
||||
for (const status of ["incomplete", "unpaid", "canceled", "paused", "trialing"]) expect(usableGrant({ status, until })).to.equal(false);
|
||||
expect(usableGrant({ status: "active", until: new Date(0) })).to.equal(false);
|
||||
expect(usableGrant({ status: "active", until, revoked: true })).to.equal(false);
|
||||
});
|
||||
it("does not activate a paid conference from a submitted form or an edit", () => {
|
||||
const model = {};
|
||||
const body = { plan: { planID: "conference" }, startDate: "2026-11-01", endDate: "2027-01-01" };
|
||||
applyConferenceForm(model, body, true);
|
||||
expect(model.status).to.equal("pending_payment");
|
||||
applyConferenceForm(model, body, false);
|
||||
expect(model.status).to.equal("pending_payment");
|
||||
model.status = "removed";
|
||||
applyConferenceForm(model, body, false);
|
||||
expect(model.status).to.equal("removed");
|
||||
});
|
||||
it("requires a finite dated expiration when billing is enabled", () => {
|
||||
const { validateBillingExpiration } = require("../src/core/billing/expiration");
|
||||
const enabled = config.BILLING_ENABLED;
|
||||
config.BILLING_ENABLED = true;
|
||||
try {
|
||||
for (const options of [{}, { expirationMode: "remove" }, { expirationMode: "remove", expirationDate: "invalid" },
|
||||
{ expirationMode: "remove", expirationDate: "2099-01-01" }]) {
|
||||
expect(() => validateBillingExpiration(options)).to.throw("invalid_billing_expiration");
|
||||
}
|
||||
expect(() => validateBillingExpiration({ expirationMode: "never" })).not.to.throw();
|
||||
expect(() => validateBillingExpiration({ expirationMode: "remove", expirationDate: new Date() })).not.to.throw();
|
||||
} finally { config.BILLING_ENABLED = enabled; }
|
||||
});
|
||||
it("rejects unsafe configured redirect origins", () => {
|
||||
const original = config.BILLING_ORIGIN;
|
||||
try {
|
||||
for (const value of ["http://example.org", "https://user:pass@example.org", "https://example.org/path", "https://example.org?next=x"]) {
|
||||
config.BILLING_ORIGIN = value;
|
||||
expect(() => billingOrigin()).to.throw();
|
||||
}
|
||||
config.BILLING_ORIGIN = "https://example.org";
|
||||
expect(billingOrigin()).to.equal("https://example.org");
|
||||
} finally { config.BILLING_ORIGIN = original; }
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
const { setImmediate } = require("timers");
|
||||
const { URLSearchParams } = require("node:url");
|
||||
const source = fs.readFileSync(path.join(__dirname, "../public/script/app.js"), "utf8");
|
||||
|
||||
function harness(date) {
|
||||
@@ -11,7 +12,7 @@ function harness(date) {
|
||||
const context = { reactive: value => value, console, Map, Set, Date: date || Date,
|
||||
formDraft: state => ({ restore: () => false, clear() {}, discard() {}, checkpoint: () => ({ terms: state.terms, options: state.options }), accept() {} }),
|
||||
navigator: { platform: "Linux" }, document: { location: { pathname: "/r/repo" }, addEventListener() {}, querySelector() {} },
|
||||
window: {}, Prism: { highlightAll() {} }, encodeURIComponent,
|
||||
URLSearchParams, window: { location: { search: "" } }, Prism: { highlightAll() {} }, encodeURIComponent,
|
||||
encodePathForUrl: p => p.split("/").map(encodeURIComponent).join("/"),
|
||||
humanFileSize: x => String(x), parseGithubUrl: () => ({ owner: "owner", repo: "repo" }),
|
||||
$: () => ({ on() {}, tooltip() {} }),
|
||||
@@ -20,6 +21,7 @@ function harness(date) {
|
||||
};
|
||||
context.createTimers = () => ({ timeout: Object.assign(context.setTimeout, { cancel: context.clearTimeout }), interval: Object.assign(context.setInterval, { cancel: context.clearInterval }) });
|
||||
context.createListeners = () => (target, name, callback) => target.addEventListener(name, callback);
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../public/script/billing.js"), "utf8").replaceAll("export function", "function"), context);
|
||||
const names = [...source.matchAll(/export const (\w+)/g)].map(match => match[1]);
|
||||
vm.runInNewContext(source.replace(/^import .*;$/gm, "").replace(/export const/g, "var") + "\nthis.pageSetups = {" + names.join(",") + "};", context);
|
||||
Object.assign(defs, context.pageSetups);
|
||||
@@ -59,7 +61,7 @@ describe("frontend production regressions", function () {
|
||||
for (const id of ["repoId", "pullRequestId", "gistId"]) {
|
||||
it(`preserves saved options when reopening a conference-linked ${id}`, async function () {
|
||||
const h = anonymize({ [id]: "saved" });
|
||||
h.requests[0].resolve({ data: { options: { update: true } } }); await h.flush();
|
||||
h.requests.find(r => r.url === "/api/user/default").resolve({ data: { options: { update: true } } }); await h.flush();
|
||||
h.requests.at(-1).resolve({ data: {
|
||||
source: { fullName: "owner/repo", repositoryFullName: "owner/repo", branch: "main", commit: "abcdef", gistId: "123", pullRequestId: 1 },
|
||||
conference: "conf",
|
||||
|
||||
+251
-2
@@ -80,6 +80,126 @@ describe("Vue 3 UI", function () {
|
||||
let ui;
|
||||
afterEach(() => ui?.close());
|
||||
|
||||
it("keeps defaults across settings tabs and preserves the hidden fetch preference", async () => {
|
||||
ui = await browser("/profile", {
|
||||
"/api/user": { username: "owner" },
|
||||
"/api/user/default": { terms: ["Original"], options: { mode: "GitHubDownload", link: true } },
|
||||
});
|
||||
expect(ui.window.document.querySelector("#mode")).to.equal(null);
|
||||
await ui.input("#terms", "Changed Author");
|
||||
ui.window.document.querySelectorAll(".settings-nav button")[1].click();
|
||||
await delay(300);
|
||||
ui.window.document.querySelectorAll(".settings-nav button")[0].click();
|
||||
await delay(10);
|
||||
expect(ui.window.document.querySelector("#terms").value).to.equal("Changed Author");
|
||||
ui.window.document.querySelector("#save").click(); await delay(30);
|
||||
const saved = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/user/default");
|
||||
expect(saved.payload.options.mode).to.equal("GitHubDownload");
|
||||
expect(saved.payload.terms).to.deep.equal(["Changed Author"]);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("shows paid-through access and billing management without offering a duplicate subscription", async () => {
|
||||
ui = await browser("/profile", {
|
||||
"/api/user": { username: "owner" },
|
||||
"/api/billing/": { enabled: true, csrf: "token", portal: true, allowance: { repositories: 20, storage: 10737418240 },
|
||||
grants: [{ planId: "author_monthly", scope: "user:owner", status: "active", until: "2099-12-01", cancelAtPeriodEnd: true }],
|
||||
plans: [{ id: "author_monthly", amount: 500, currency: "eur", interval: "month" }] },
|
||||
});
|
||||
const panel = ui.window.document.querySelector("#settings-billing");
|
||||
expect(panel.textContent).to.include("Renewal cancelled");
|
||||
expect(panel.textContent).to.include("Manage billing");
|
||||
expect(panel.textContent).not.to.include("View Author plan");
|
||||
expect(panel.querySelector(".billing-plan")).to.equal(null);
|
||||
expect(ui.window.document.querySelector(".nav-upgrade")).to.equal(null);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("requires the account name before submitting account deletion", async () => {
|
||||
ui = await browser("/profile", { "/api/user": { username: "owner" }, "/api/user/": {} });
|
||||
ui.window.document.querySelectorAll(".settings-nav button")[2].click(); await delay(10);
|
||||
ui.window.document.querySelector(".paper-danger-zone button").click(); await delay(10);
|
||||
const confirmation = ui.window.document.querySelector("#delete-username");
|
||||
const button = ui.window.document.querySelector(".settings-delete-confirm .btn-outline-danger");
|
||||
expect(button.disabled).to.equal(true);
|
||||
confirmation.value = "wrong"; confirmation.dispatchEvent(new ui.window.Event("input", { bubbles: true })); await delay(10);
|
||||
expect(button.disabled).to.equal(true);
|
||||
confirmation.value = "owner"; confirmation.dispatchEvent(new ui.window.Event("input", { bubbles: true })); await delay(10);
|
||||
expect(button.disabled).to.equal(false);
|
||||
expect(ui.requests.some(r => r.method === "DELETE")).to.equal(false);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("opens the plan comparison from the global header on another page", async () => {
|
||||
ui = await browser("/dashboard", {
|
||||
"/api/user": { username: "author" },
|
||||
"/api/billing/": { enabled: true, csrf: "token", grants: [], allowance: { repositories: 5, storage: 2147483648 }, plans: [{ id: "author_monthly", amount: 500, currency: "eur", interval: "month", repositories: 20, storage: 10737418240 }] },
|
||||
});
|
||||
const upgrade = ui.window.document.querySelector(".app-header .nav-upgrade");
|
||||
expect(upgrade).not.to.equal(null);
|
||||
expect(ui.window.document.querySelector(".nav-support")).not.to.equal(null);
|
||||
upgrade.click(); await delay(100);
|
||||
expect(ui.window.document.querySelector(".settings-comparison")).not.to.equal(null);
|
||||
expect(ui.window.document.querySelectorAll(".settings-nav button")[1].getAttribute("aria-current")).to.equal("page");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("shows subscription cadence and sends a protected checkout request", async () => {
|
||||
ui = await browser("/profile", {
|
||||
"/api/user": { username: "author" },
|
||||
"/api/billing/": { enabled: true, csrf: "billing-token", portal: true, grants: [], allowance: { repositories: 3, storage: 512 }, plans: [
|
||||
{ id: "author_monthly", name: "Author monthly", description: "Renews monthly. Cancel renewal at any time.", amount: 500, currency: "eur", interval: "month", intervalCount: 1, repositories: 20, storage: 10737418240 },
|
||||
] },
|
||||
"/api/billing/checkout": { __status: 409, body: { error: "billing_subscription_exists" } },
|
||||
});
|
||||
ui.window.document.querySelector(".nav-upgrade").click();
|
||||
await delay(10);
|
||||
expect(ui.window.document.activeElement.id).to.equal("settings-plan-comparison");
|
||||
expect(ui.window.document.querySelector(".settings-comparison").textContent).to.include("Scheduled or never");
|
||||
expect(ui.window.document.querySelectorAll(".settings-nav button")[1].getAttribute("aria-current")).to.equal("page");
|
||||
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("/ month");
|
||||
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("10 GiB");
|
||||
ui.window.document.querySelector(".billing-plan button").click();
|
||||
await delay(30);
|
||||
const request = ui.requests.find(r => r.url.pathname === "/api/billing/checkout");
|
||||
expect(request.payload).to.deep.equal({ planId: "author_monthly", target: "" });
|
||||
expect(request.headers["x-csrf-token"]).to.equal("billing-token");
|
||||
expect(ui.window.document.querySelector("#settings-billing [role=alert]").textContent).to.include("already have a subscription");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("refreshes the quota panel when checkout confirmation changes the allowance", async () => {
|
||||
let confirmed = false;
|
||||
let quotaReads = 0;
|
||||
ui = await browser("/profile?billing=success", {
|
||||
"/api/user": { username: "author" },
|
||||
"/api/billing/": () => ({ enabled: true, csrf: "billing-token", grants: [], plans: [], allowance: { repositories: confirmed ? 20 : 5, storage: confirmed ? 10737418240 : 2147483648 } }),
|
||||
"/api/user/quota": () => { quotaReads++; return { repository: { used: 1, total: confirmed ? 20 : 5 }, storage: { used: 128, total: confirmed ? 10737418240 : 2147483648 } }; },
|
||||
"/api/billing/refresh": async () => { await delay(60); confirmed = true; return { ok: true, pending: false }; },
|
||||
});
|
||||
await delay(150);
|
||||
expect(quotaReads).to.equal(2);
|
||||
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("20 repositories");
|
||||
expect(ui.window.document.querySelector("#settings-quota").textContent).to.match(/\/\s*20/);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("offers payment for a pending conference without activating it in the browser", async () => {
|
||||
ui = await browser("/conference/CONF", {
|
||||
"/api/user": { username: "chair" },
|
||||
"/api/conferences/CONF": { conferenceID: "CONF", name: "Research conference", status: "pending_payment", repositories: [], plan: { planID: "conference" } },
|
||||
"/api/billing/": { enabled: true, csrf: "billing-token", grants: [], plans: [{ id: "conference", amount: 19900, currency: "eur", repositories: 100, storage: 1000 }] },
|
||||
"/api/billing/checkout": { __status: 400, body: { error: "billing_invalid_conference" } },
|
||||
});
|
||||
const section = ui.window.document.querySelector('[aria-labelledby="conference-payment-heading"]');
|
||||
expect(section.textContent).to.include("waiting for payment");
|
||||
section.querySelector("button").click();
|
||||
await delay(30);
|
||||
expect(ui.requests.find(r => r.url.pathname === "/api/billing/checkout").payload).to.deep.equal({ planId: "conference", target: "CONF" });
|
||||
expect(section.textContent).to.include("Only a conference owner");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
const repositorySource = {
|
||||
"/api/user": { username: "owner" },
|
||||
"/api/user/default": { terms: ["Default Author"], options: {} },
|
||||
@@ -89,6 +209,135 @@ describe("Vue 3 UI", function () {
|
||||
"/api/anonymize-preview": request => ({ content: request.payload.content.replaceAll("Private Author", "MASKED") }),
|
||||
};
|
||||
|
||||
const taskBilling = { enabled: true, csrf: "token", allowance: { plan: "free", repositories: 5 }, plans: [
|
||||
{ id: "author_monthly", amount: 500, currency: "eur", interval: "month", repositories: 20, storage: 10737418240, description: "Renews monthly." },
|
||||
] };
|
||||
it("saves the repository draft before contextual checkout and preserves a failed checkout", async () => {
|
||||
ui = await browser("/anonymize", { ...repositorySource,
|
||||
"/api/billing/": taskBilling, "/api/user/quota": { repository: { used: 5, total: 5 } },
|
||||
"/api/billing/draft": { id: "saved-task" },
|
||||
"/api/billing/checkout": { __status: 503, body: { error: "billing_unavailable" } },
|
||||
});
|
||||
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
|
||||
state.sourceUrl = "https://github.com/owner/repo";
|
||||
await state.urlSelected(); await delay(60);
|
||||
await ui.input("#terms", "Uncommitted redaction");
|
||||
await state.checkoutRepository();
|
||||
const saved = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/billing/draft");
|
||||
expect(saved.payload.path).to.equal("/anonymize");
|
||||
expect(saved.payload.values.terms).to.equal("Uncommitted redaction");
|
||||
const checkout = ui.requests.find(r => r.url.pathname === "/api/billing/checkout");
|
||||
expect(checkout.payload.draftId).to.equal("saved-task");
|
||||
expect(state.terms).to.equal("Uncommitted redaction");
|
||||
expect(state.billingError).to.include("not available");
|
||||
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
it("restores a paid checkout task without submitting it and rechecks the allowance", async () => {
|
||||
ui = await browser("/anonymize?billingDraft=saved-task&billing=success", { ...repositorySource,
|
||||
"/api/billing/": { ...taskBilling, allowance: { plan: "author_monthly", repositories: 20 } },
|
||||
"/api/billing/refresh": { ok: true, pending: false },
|
||||
"/api/billing/draft/saved-task": { id: "saved-task", path: "/anonymize", values: { sourceUrl: "https://github.com/owner/repo", terms: "Restored Author", repoId: "saved-repo", source: { branch: "main" }, options: { expirationMode: "never" } } },
|
||||
});
|
||||
await delay(150);
|
||||
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.terms).to.equal("Restored Author");
|
||||
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.repoId).to.equal("saved-repo");
|
||||
expect(ui.app.router.currentRoute.value.query).to.deep.equal({});
|
||||
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.hostingNeedsPayment()).to.equal(false);
|
||||
expect(ui.window.document.querySelector("#repository-hosting").textContent).to.include("subscription is active");
|
||||
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
it("keeps all settings and the live preview visible in the split workspace", async () => {
|
||||
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling,
|
||||
"/api/user/quota": { repository: { used: 5, total: 5 } } });
|
||||
const doc = ui.window.document;
|
||||
const state = doc.querySelector("#sourceUrl")._field.binding.state;
|
||||
state.sourceUrl = "https://github.com/owner/repo";
|
||||
await state.urlSelected(); await delay(60);
|
||||
expect(doc.querySelector('.repository-task')).to.equal(null);
|
||||
for (const selector of ['#sourceUrl', '#terms', '#image', '#conference', '#expirationDate']) {
|
||||
expect(doc.querySelector(selector).closest('details')).to.equal(null);
|
||||
}
|
||||
state.terms = "Private Author";
|
||||
await delay(450);
|
||||
expect(doc.querySelector('.anonymize-preview-body').textContent).to.include("MASKED");
|
||||
expect(doc.querySelector('.anonymize-preview-body').textContent).not.to.include("Private Author");
|
||||
expect(doc.querySelector('.subscription-choice').textContent).to.include("20 repositories");
|
||||
expect(doc.querySelector('.anonymize-submit-bar').textContent).to.include("Continue to checkout");
|
||||
doc.querySelector('.preview-expand').click(); await delay(10);
|
||||
expect(doc.querySelector('.anonymize-page').classList.contains('preview-expanded')).to.equal(true);
|
||||
doc.querySelector('.preview-expand').click(); await delay(10);
|
||||
expect(state.terms).to.equal("Private Author");
|
||||
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("keeps checkout failures and pending payment in the editor without losing input", async () => {
|
||||
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling,
|
||||
"/api/user/quota": { repository: { used: 5, total: 5 } },
|
||||
"/api/billing/draft": { id: "saved-task" },
|
||||
"/api/billing/checkout": { __status: 503, body: { error: "billing_unavailable" } } });
|
||||
const doc = ui.window.document;
|
||||
const state = doc.querySelector("#sourceUrl")._field.binding.state;
|
||||
state.sourceUrl = "https://github.com/owner/repo";
|
||||
await state.urlSelected(); await delay(60);
|
||||
expect(doc.querySelector("#expirationDate").value).to.match(/^\d{4}-\d{2}-\d{2}$/);
|
||||
await ui.input("#terms", "Preserved Author");
|
||||
doc.querySelector(".anonymize-submit-bar button").click(); await delay(80);
|
||||
expect(doc.querySelector("#repository-hosting").textContent).to.include("Payments are not available");
|
||||
expect(state.terms).to.equal("Preserved Author");
|
||||
expect(ui.requests.find(r => r.url.pathname === "/api/billing/draft").payload.values.terms).to.equal("Preserved Author");
|
||||
state.billingPending = true; await delay(20);
|
||||
expect(doc.querySelector("#repository-hosting").textContent).to.include("Confirming your payment");
|
||||
expect(doc.querySelector(".subscription-choice")).to.equal(null);
|
||||
expect(doc.querySelector(".anonymize-submit-bar button").textContent).to.equal("Check payment status");
|
||||
const checkoutCount = ui.requests.filter(r => r.url.pathname === "/api/billing/checkout").length;
|
||||
await state.submitRepository({});
|
||||
expect(ui.requests.filter(r => r.url.pathname === "/api/billing/checkout")).to.have.length(checkoutCount);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
for (const status of ["preparing", "ready", "error"]) {
|
||||
it(`keeps the task summary through repository ${status} without a premature ready link`, async () => {
|
||||
ui = await browser("/status/saved", { "/api/repo/saved": { repoId: "saved", status,
|
||||
source: { fullName: "owner/repo" }, options: { expirationMode: "never" } } });
|
||||
const doc = ui.window.document;
|
||||
expect(doc.querySelector('.task-trail [aria-current="step"]').textContent).to.include("Review link");
|
||||
expect(doc.querySelector(".task-summary").textContent).to.include("owner/repo");
|
||||
expect(!!doc.querySelector(".task-ready")).to.equal(status === "ready");
|
||||
expect(!!doc.querySelector('.task-preparation a[href="/r/saved/"]')).to.equal(status === "ready");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps free, conference and coauthor work out of author checkout", async () => {
|
||||
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling });
|
||||
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state; state.detectedType = "repo";
|
||||
state.hostingQuota = { repository: { used: 1, total: 5 } };
|
||||
expect(state.hostingNeedsPayment()).to.equal(false);
|
||||
state.hostingQuota.repository.used = 5;
|
||||
expect(state.hostingNeedsPayment()).to.equal(true);
|
||||
state.conference = "CONF";
|
||||
expect(state.hostingNeedsPayment()).to.equal(false);
|
||||
state.options.expirationMode = "never";
|
||||
expect(state.hostingNeedsPayment()).to.equal(true);
|
||||
state.isUpdate = true; state.role = "coauthor";
|
||||
expect(state.hostingNeedsPayment()).to.equal(false);
|
||||
});
|
||||
it("refreshes the conference in place after checkout confirmation", async () => {
|
||||
let confirmed = false;
|
||||
ui = await browser("/conference/CONF?billing=success", { "/api/user": { username: "chair" },
|
||||
"/api/billing/": taskBilling,
|
||||
"/api/billing/refresh": () => { confirmed = true; return { ok: true, pending: false }; },
|
||||
"/api/conferences/CONF": () => ({ conferenceID: "CONF", name: "Conference", status: confirmed ? "ready" : "pending_payment", repositories: [] }),
|
||||
});
|
||||
await delay(80);
|
||||
expect(ui.window.document.body.textContent).to.include("Ready");
|
||||
expect(ui.window.document.querySelector('[aria-labelledby="conference-payment-heading"]')).to.equal(null);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("keeps the current repository when earlier metadata and README responses arrive late", async () => {
|
||||
let finishMetadata, finishReadme;
|
||||
ui = await browser("/anonymize", {
|
||||
@@ -309,7 +558,7 @@ describe("Vue 3 UI", function () {
|
||||
[...preview().querySelectorAll("button")].find(b => b.textContent === "Retry preview").click();
|
||||
await delay(260);
|
||||
expect(preview().textContent).to.include("MASKED");
|
||||
expect(preview().textContent).to.include("redactions applied");
|
||||
expect(preview().querySelector(".anonymize-preview-body")).not.to.equal(null);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
}
|
||||
@@ -756,7 +1005,7 @@ describe("Vue 3 UI", function () {
|
||||
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
|
||||
"/api/repo/owner/repo/readme": "",
|
||||
});
|
||||
const button = [...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes("Update "));
|
||||
const button = [...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes(type === "repo" ? "Save changes" : "Update "));
|
||||
const posts = () => ui.requests.filter(r => r.method === "POST" && r.url.pathname === endpoint);
|
||||
button.click();
|
||||
await delay(10);
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("ZIP proxy failures", function () {
|
||||
return http.get(`http://127.0.0.1:${server.address().port}/test/zip`, { headers }, onResponse);
|
||||
}
|
||||
it("answers HEAD without counting a view, obtaining a token or starting a ZIP stream", async function () {
|
||||
utils.getRepo = async () => ({ repoId: "test", countView: () => { throw Error("view counted"); },
|
||||
utils.getRepo = async () => ({ repoId: "test", model: {}, countView: () => { throw Error("view counted"); },
|
||||
getToken: () => { throw Error("token requested"); }, generateAnonymizeTransformer: () => { throw Error("transform requested"); } });
|
||||
got.stream = () => { throw Error("archive requested"); };
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
|
||||
Reference in new issue
Block a user