diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b22f980 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,47 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Conventions + if: github.event_name == 'pull_request' + run: python3 scripts/check_conventions.py --base=origin/${{ github.base_ref }} + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install OSL + env: + OSL_COMMIT: 5895982fa61d3e7c9e9c7f819c9e33f645d04f0f + run: | + mkdir -p "$HOME/.local/bin" + git clone --filter=blob:none https://git.rotur.dev/osl/.go /tmp/osl-source + git -C /tmp/osl-source checkout "$OSL_COMMIT" + (cd /tmp/osl-source && go build -buildvcs=false -o "$HOME/.local/bin/osl" .) + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: OSL version + run: osl version + + - name: Type check + run: osl lsp check main.osl + + - name: Test + run: osl test + + - name: Compile + run: osl compile main.osl -o "$RUNNER_TEMP/mwapi" + + - name: Smoke test + run: python3 scripts/e2e_smoke.py "$RUNNER_TEMP/mwapi" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..96eeae5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md + +mistwarp-api is the OSL backend behind https://api.mistwarp.org/v1, for the MistWarp community site and editor (scratch-gui). It needs OSL 3.0. Community data is in SQLite (`data/community.db`), other state is JSON under `data/`, and project files are in Cloudflare R2. + +## Where code goes + +- `main.osl` only composes the app: it imports every domain, runs startup and migrations, registers routes and starts the server. Don't put features in it. +- `src//` holds everything else (config, core, http, auth, storage, projects, history, git, games, realtime, community, admin, classroom, rotur). The [Backend Overview](https://mistwarp.org/docs/backend/overview/) describes what each owns. +- Each file has an `export {...}` block after its imports, listing what other directories may use; anything not listed is private. Keep exports minimal. +- A domain registers its routes in its own `routes.osl` (`registerXRoutes(api)`). Global caches live next to the code that uses them. Tests sit beside their source as `.test.osl`. + +## The /v1 contract + +The editor and site depend on every path, method, middleware order, body limit and response shape. Don't change them unless the task is to. `python3 scripts/route_dump.py .` prints every route with its middleware, so diff its output before and after any routing change. `python3 scripts/e2e_smoke.py ` runs an offline end-to-end pass against two builds and diffs their responses. + +## OSL traps + +- String `.len` counts characters. Use `.toBytes().len` for anything measured in bytes: stored sizes, quotas, body caps, R2 objects. +- Arrays are shared on assignment and when passed to a function. `.assert(array)`, `?? []` and function returns copy, so after changing a copied array, write it back (`obj.field = list`). +- `result`, `set`, `error`, `option`, `xml`, `thread`, `unknown` and `channel` are reserved type names, so don't use them as variable names. +- The runtime throws where older versions returned defaults. Read request values as `(c.query("x") ?? "")`, parse bodies with `requestJSON(c)`, discard unused calls with `void`, and end fallible reads with `.unwrapOr()`. + +## Checks + +`osl test`, `osl lsp check main.osl`, `osl compile main.osl -o main`, and `python3 scripts/check_conventions.py`. The last one rejects code comments and em dashes in the lines you add. CI (`.github/workflows/test.yml`) runs all of them plus the smoke test, with OSL pinned to a commit. + +## Working here + +- The default branch is `main`. Open PRs with `gh pr create -R MistWarp/api --base main`. +- Never deploy, restart or SSH to the production server unless explicitly asked. +- New behaviour comes with tests. A refactor keeps the route dump and the smoke diff identical. +- Never add `Co-Authored-By` or any AI or tool attribution to commits or PRs. +- READMEs are overviews. Documentation goes in the Backend section of MistWarp/docs (https://mistwarp.org/docs/backend/overview/). diff --git a/README.md b/README.md index 3c32d24..09a1923 100644 --- a/README.md +++ b/README.md @@ -1,258 +1,21 @@ # mistwarp-api -OSL backend for the MistWarp community platform. It provides Rotur validator auth, project metadata, Cloudflare R2 project storage, and native pull requests between MistWarp forks. Rotur Git can be connected as an optional external Git provider. +The OSL backend for the MistWarp community platform. It signs people in with Rotur, stores projects and their history in Cloudflare R2, and serves everything the community site and editor need: comments, spaces, notifications, MistWarp Classroom, game data and pull requests between MistWarp forks. -## Run +It runs at `https://api.mistwarp.org/v1`. The editor and community site are [scratch-gui](https://github.com/MistWarp/scratch-gui). -Copy `.env.example` to `.env`, fill in the values, then run: +## Getting started -``` -osl run main.osl -``` - -The server loads `.env` automatically. Configuration is read in this order: `MISTWARP_` in the real environment, then `.env`, then `` in the real environment. Put per-process overrides in `MISTWARP_` (for example `MISTWARP_PORT`) so a shared shell that exports another project's variables can't replace this API's configuration. - -## Environment - -| Variable | Default | Purpose | -| --- | --- | --- | -| PORT | 5627 | Listen port | -| HISTORY_MIGRATION_WORKERS | 4 | Concurrent background workers used to backfill missing project histories (clamped to 1-8) | -| APP_URL | https://api.mistwarp.org | Public URL of this API | -| ROTUR_APP_KEY | mistwarp | The old validator key, for editors that predate validators keyed to the Rotur App | -| COMMERCE_SERVICE_KEY | | Key registered for `mistwarp` in Rotur's `COMMERCE_SERVICE_KEYS`. Only bounty awards and the groups integration still use it | -| ROTUR_CLIENT_ID | | The MistWarp Rotur App's client ID (`app_1938b6a87799f862`). With the secret, mistwarp-api calls the apps API as MistWarp | -| ROTUR_CLIENT_SECRET | | One of the MistWarp Rotur App's secrets, made with **New secret** on rotur.dev/me/developer | -| MISTWARP_ROTUR_USER | | Rotur account that gets MistWarp's share of game product sales. Without it, the MistWarp Rotur App's owner gets it | -| ROTUR_WEBHOOK_SECRET | | Signing secret (`whsec_…`) of the MistWarp Rotur App's webhook. Without it `POST /v1/rotur/webhook` answers 503 | -| R2_ENDPOINT | | https://accountid.r2.cloudflarestorage.com | -| R2_BUCKET | mistwarp | R2 bucket name | -| R2_ACCESS_KEY_ID | | R2 access key | -| R2_SECRET_ACCESS_KEY | | R2 secret key | -| R2_PUBLIC_BASE | | Public custom domain for the bucket | -| GITEA_URL | https://git.rotur.dev | Optional Rotur Git instance | -| GITEA_ADMIN_TOKEN | | Optional Rotur Git integration token | -| EDITOR_ORIGIN | https://mistwarp.org | Public editor base URL used in links | -| ERROR_REPORT_ORIGINS | (empty) | Comma-separated origins, besides EDITOR_ORIGIN, whose error reports `POST /errors` accepts. Reports from any other origin are ignored | -| ADMIN_USERS | mist | Comma separated admin usernames | -| CLASSROOM_ENABLED | (off) | Set to `true` to open MistWarp Classroom. While it is off, every `/classroom/*` route answers 404 and student accounts cannot sign in, except for MistWarp admins and the students of classes an admin owns, so Classroom can still be tested | -| GITHUB_ORG | MistWarp | GitHub org whose pull requests appear on the roadmap's Changes tab | -| GITHUB_TOKEN | | Optional GitHub token; only raises the search rate limit, the feed works without one | -| REALTIME_URL | wss://api.mistwarp.org/v1/connect | Public multiplayer WebSocket endpoint | -| STRIPE_SECRET_KEY | | Stripe secret key for Classroom plans; online payment is off while any Stripe value is empty | -| STRIPE_WEBHOOK_SECRET | | Signing secret of the Stripe webhook pointed at `/v1/classroom/billing/webhook` | -| STRIPE_CLASSROOM_PRICE | | Recurring Stripe price for one Classroom plan | -| STRIPE_SEAT_PACK_PRICE | | Optional recurring Stripe price for each pack of 10 extra student seats | -| STRIPE_CLASSROOM_PRICE_YEARLY | | Yearly Stripe price for one Classroom plan; when set, checkout offers yearly billing and uses it by default. Online payment needs at least one of the monthly and yearly Classroom prices | -| STRIPE_SEAT_PACK_PRICE_YEARLY | | Optional yearly Stripe price for each pack of 10 extra student seats, used with the yearly Classroom price | - -The multiplayer WebSocket runs inside this API process at `/v1/connect`. It -uses the same listener, domain, and deployment as the HTTP API. - -## Bans - -A MistWarp ban is also a ban from the MistWarp Rotur App (`PUT /v2/apps//bans/`), so Rotur enforces it too: the person's MistWarp tokens stop working and Rotur won't let them sign in to MistWarp. Rotur shows them the ban's reason, and never who made it. Lifting a ban lifts both. - -Every five minutes MistWarp reads the app's bans. Bans made or lifted on rotur.dev/me/developer reach MistWarp's own list, and any ban or unban Rotur couldn't be told about yet is sent again. A ban lifted in MistWarp is never brought back from Rotur while that's pending. Rotur won't ban the app's owner or managers, classroom students, or accounts it doesn't know, so those bans stay MistWarp-only. Bans whose reason only names a report are sent with a general reason instead. - -## Plans - -Perks follow the person's Rotur plan. mistwarp-api asks the apps API (`GET /v2/apps//users//plan`), which answers whatever their privacy settings. Rotur's public profile hides the plan from anyone who can't see the balance, which covers every account without a date of birth, so it's only read for people the apps API doesn't know. Answers are kept for 5 minutes, and during an outage the last known plan stays. - -## Safety signals - -The MistWarp Rotur App declares that people can talk and can spend credits. Before a comment is posted, mistwarp-api asks Rotur's message signal about the owner of the project or profile and the author of the comment it replies to. Comments are public, like replies to Rotur posts, so only a block or a ban (`blocked`, `banned`) stops one. When someone's message settings don't take messages from the author (`settings`, `unavailable`), the comment is still posted but doesn't notify them, and the same goes for replies, mentions and other notifications that carry someone's words. Before a purchase or donation starts, it asks the purchase signal. A "no" is shown to the person in Rotur's own words. - -Rotur only answers about people who have used MistWarp through Sign in with Rotur, or made their account on MistWarp. For anyone else, or if Rotur can't be reached, MistWarp carries on as it did before. Rotur counts credits towards a parent's monthly limit when it moves them, so the purchase signal only asks. - -## Payments - -Buying a game product, donating with a comment and refunding a game product are Rotur payment requests (`POST /v2/apps//payment-requests`). mistwarp-api never holds a permission to spend anyone's credits. - -1. The editor asks for an intent. mistwarp-api asks Rotur for the payment, with a purchase key (`mwbuy_`, `mwgame_`, `mwdonate_` or `mwrefund_`) as its `reference`, and answers with the key and Rotur's `approveUrl`. -2. The person approves it on rotur.dev with their password. Rotur moves the credits, shared between everyone the sale pays: the creator, collaborators, the remixed project's creator and MistWarp's fee. -3. Rotur's `payment.completed` webhook marks the key paid and delivers game products. The editor's confirm call does the same if it gets there first, asking Rotur whether the request was paid. Whichever comes second finds it done. - -Donations are delivered when their comment is posted, and refunds when the product is revoked. Paid keys waiting for that are kept for 30 days, and unpaid ones for an hour (Rotur's requests expire after 15 minutes). - -### Project sales have ended - -Projects can no longer be sold. `POST /v1/projects/:id/purchase/intent` and `/purchase/confirm` answer 410 with the code `project_sales_ended`, and a `PUT /v1/projects/:id` that sets a price above 0 answers 400 with the same code. A project that had a price plays, and can be remixed and seen inside, the same as a free one with the same settings. Remixes of paid projects are no longer kept private. The webhook still records a `mwbuy_` payment that was started before sales ended, so it can be refunded. - -What was sold stays readable: buyers keep their records in `GET /v1/me/purchases`, and creators keep the buyers, revenue and sales history in their project analytics and `GET /v1/me/stats`. - -`scripts/refund_project_sales.py` pays every buyer back the full price of every project they bought, from one Rotur account, with one transfer per buyer (`POST /v2/me/transfers`). It sends nothing without `--apply`. Each transfer is written to `data/project-sale-refunds.json` before it is sent and marked when Rotur answers, so running it again never pays a purchase twice. It stops if a project purchase can still be paid on Rotur, if the token doesn't belong to `--payer`, if the payer doesn't have enough credits, or if an earlier transfer's outcome is unknown (check the payer's Rotur transactions, then `--resolve =sent` or `=failed`). +You need OSL 3.0. ```sh -python3 scripts/refund_project_sales.py -ROTUR_REFUND_TOKEN= python3 scripts/refund_project_sales.py --apply --payer -``` - -Tests: `python3 -m unittest scripts/test_refund_project_sales.py`, and with a compiled binary `MWAPI_BIN= python3 -m unittest scripts/test_project_sales_api.py`. - -## Error reports - -The editor and community site send uncaught errors to `POST /v1/errors`, from `EDITOR_ORIGIN` or an origin in `ERROR_REPORT_ORIGINS`. A report holds the error message and stack, the kind of page (its address with IDs, usernames, query and fragment removed), the browser name, major version and operating system, the viewport, the app version and the time. It never holds the username, the project ID, the full user agent or the IP address, and resolving a report does not record who resolved it. Email addresses, URL queries, avatar usernames and usernames, class, student or project IDs in API and file paths are removed from the message and stack too. Admins read them through `GET /v1/admin/errors`. - -Reports are kept for 7 days, resolved or not, and only the newest 1,000 are kept at all. Older ones are deleted when a new report comes in, when the server starts and every five minutes. Reports stored before this change are scrubbed the same way by the next sweep. - -## Rotur webhooks - -Set the MistWarp Rotur App's webhook to `https://api.mistwarp.org/v1/rotur/webhook` on rotur.dev/me/developer, with privacy requests on, and put its signing secret in `ROTUR_WEBHOOK_SECRET`. - -- `user.deleted`, `user.left` and `user.banned` erase everything MistWarp holds about that person, the same as deleting their data from Settings. -- A `privacy.request` for `erasure` does the same. One for `access` sends them a MistWarp notification pointing to the download in Settings. -- `payment.completed` finishes the purchase it names (see Payments). - -Deliveries are checked against `Rotur-Signature` and refused if their timestamp is more than five minutes out. Each is saved to `data/rotur-webhooks.json`, answered, then handled in the background, and a retried delivery is only handled once. Anything not yet handled when the server stops is handled when it starts. - -Rotur only sends these for people who have used MistWarp through Sign in with Rotur, or whose account was made on MistWarp. The five-minute check of `/accounts/deleted_check` stays for everyone else. - -## Reports - -Every report is also filed in the MistWarp Rotur App's report queue (`POST /v2/apps//reports`), naming the reporter when Rotur knows them as a MistWarp user. Reports use Rotur's categories. One in a priority category (`csea`, `threat_to_life`, `self_harm`, `terrorism`) goes to Rotur's safety team as soon as it's filed. MistWarp's own queue and actions are unchanged. Dismissing a report closes it on Rotur as dismissed, and any other action closes it as resolved, except reports Rotur is still reviewing. - -Who a report is about, and the snapshot sent with it, are worked out by mistwarp-api from the content when the report is made, never taken from the reporter: a project's owner and its title, description, instructions and notes; a comment's author and its text; a profile's owner and bio. Moderation actions on a report (ban, warn) use the same person. A report whose subject can't be found stays MistWarp-only, because Rotur needs someone to name. - -A report Rotur couldn't be reached for stays pending and is filed by the five-minute reconcile loop. One closed in MistWarp before Rotur had it is closed on Rotur as soon as it's filed. - -## Badges - -MistWarp gives badges on people's Rotur profiles as the MistWarp Rotur App. Which badges exist, and what earns them, is data. Define the badges on rotur.dev/me/developer, then map MistWarp's events to them in `data/rotur-badges.json`: - -```json -{"events": { - "project_shared": [{"badge": "creator", "delta": 1}], - "love_received": [{"badge": "loved", "delta": 1}], - "remixed": [{"badge": "remixed", "delta": 1}], - "comment_posted": [{"badge": "commenter", "delta": 1}], - "streak": [{"badge": "streak", "progress": "value"}] -}} +cp .env.example .env +osl run main.osl +osl test ``` -| Event | When | -| --- | --- | -| `project_shared` | Someone shares a project for the first time | -| `love_received` | Someone else loves their project, counted once per person per project | -| `remixed` | Someone else shares a remix of their project | -| `comment_posted` | They post a comment anywhere | -| `streak` | They save a project on a new day. The value is how many days in a row they have done so | - -`delta` adds to their progress. `"progress": "value"` sets it to the event's value. A rule with neither is skipped. Updates are sent in the background, and Rotur only gives badges to people who have used MistWarp through Sign in with Rotur. Without the file, nothing is sent. The file is read on every event, so changing it needs no restart. - -## Development feed - -`/v1/development/pulls` lists MistWarp's own open and recently merged pull -requests, and `/v1/roadmap` resolves the ones linked to each entry. - -GitHub's search quota is 10 requests a minute for an unauthenticated caller and -is counted per IP, so the feed is built to stay well inside it: - -- One search covers the whole org, so a refresh costs two requests rather than - one per repository. -- A refresh happens at most once every five minutes. The snapshot is stored, so - a restart reuses it and costs nothing — a crash loop cannot burn the budget. -- Every response's `X-RateLimit-*` headers are recorded, and a refresh is - refused when the remaining quota would not cover it, keeping a small reserve - for anything else on the same address. -- A 403 or 429 backs off until the window reopens, honouring `Retry-After`. - -Search responses carry no `ETag` and are sent `no-cache`, so a conditional -request cannot earn a free 304; not spending the request is the only lever. - -`GITHUB_TOKEN` raises the ceiling but is not required. - -## Deployment layout - -The site and editor are one scratch-gui build (community pages live in -`scratch-gui/src/community`), served on the frontend domain: - -| Path | Serves | -| --- | --- | -| / | community app (webpack `community` entry -> index.html) | -| /editor | scratch-gui editor | -| /embed.html | scratch-gui embed player (project pages iframe this) | -| /project/*, /explore, /users/*, /settings | community app (client routing) | - -This API runs at `api.mistwarp.org`, with `mwapi.mistium.com` retained as a -backwards-compatible hostname. The frontend calls it at -`https://api.mistwarp.org/v1` directly. In dev, leave the API base unset and -webpack-dev-server proxies `/v1` to `http://localhost:5627`. Auth is -Bearer-token based (the session token returned by `/v1/auth`), so a -cross-domain API works without shared cookies. CORS echoes the request Origin -with credentials, so any frontend origin is accepted. - -`/v1` is the canonical route group. Every route is also registered under -`/api` for backwards compatibility, including uploads and their larger body -limits. New clients and generated URLs must use `/v1`. - -R2 bucket only needs public GET through `R2_PUBLIC_BASE`, and the public domain must expose the bucket's `assets/` prefix and allow CORS GET requests from the MistWarp frontend. Project metadata keeps the API `/blobs/assets` base. That endpoint serves a local asset when its file exists under `data/blobs/assets/`; otherwise it redirects to `R2_PUBLIC_BASE`. All writes go through this server. With no R2 configured, the server falls back to local disk (`data/blobs/`, served at `/blobs`) so it runs locally with zero setup. - -## Upload pipeline - -The editor POSTs a sparse sb3 to `POST /v1/projects/:id/upload` (multipart, fields `project` and optional `thumbnail`). The server extracts at most 256 MiB of `project.json`, validates it incrementally, requires asset filenames to match their content, and limits every asset to 10 MiB and all assets to 50 MiB. The JSON is accepted only when its gzip representation is at most 20 MiB. - -### Commit inspection - -The read-only commit endpoints inspect the stored `.mwp` on the server. Clients do not need to download the complete workspace to render history or browse an old revision. - -- `GET /v1/projects/:id/commits/:sha` returns commit metadata, its first parent, and changed-file records. Each record has `path`, `status`, `oldOid`, `newOid`, `oldSize`, and `newSize`. With `?inline=1`, small text changes also include `oldData` and `newData`, encoded as base64 and capped at 1 MiB per file and 8 MiB per response. Root commits report every file as added. `legacy: true` means the change includes an old `project.sb3`; clients that need the expanded Fractch diff should use their legacy conversion fallback. -- `GET /v1/projects/:id/commits/:sha/tree` returns `commit` and a flat `files` array. Each file has `path`, `oid`, `mode`, `size`, and `binary`. -- `GET /v1/projects/:id/commits/:sha/file?path=` returns one file record and `content`, encoded as base64 by JSON. -- `GET /v1/projects/:id/commits/:sha/co-authors` returns the Rotur users credited on that commit. The older `/collaborators` path remains an alias. -- `PATCH /v1/projects/:id/commits/:sha` accepts `{coAuthors: string[]}` with at most 25 existing Rotur usernames. `{collaborators: string[]}` remains an input alias. It replaces the commit's co-author metadata without rewriting Git objects or changing any SHA. Responses expose canonical `coAuthors` records as `{username, userId}` and a compatibility `collaborators` alias. Only the project owner, a maintainer, or an administrator may patch commit credits. - -These routes use the same see-inside policy as workspace downloads. The server caches materialized workspace layers and computed JSON by the ordered content-addressed layer keys. Public projects may be cached by the CDN for one hour; private and unlisted responses use `private, no-store`. Stable ETags let browsers revalidate without reparsing Git objects. The local inspection cache keeps at most 256 files or 2 GiB and removes entries older than 24 hours. - -Before extraction, the API rejects archives with unsafe paths, duplicate entries, symlinks, unsupported compression methods, or more entries and expanded bytes than the endpoint allows. MistWarp history archives are capped at 128 MiB compressed and 20,000 entries. Their expanded-byte ceiling matches the account's maximum project size, including its tier-specific asset allowance. The API reads each history entry through that ceiling before storing it, so forged ZIP metadata cannot bypass the limit. - -- `assets/`: content addressed, shared across all projects and remixes, uploaded once ever -- `projects//project.json`: the gzip-encoded playable snapshot -- `projects//thumb.png` - -Project JSON, history archives, and assets are staged on local disk before the upload request returns. The API serves those files locally until the project has been inactive for seven days. Every edit restarts that window, including saves with unchanged content. Shared files wait until every project referencing them has been inactive for seven days. The background loop checks every five minutes, uploads eligible files to R2, and removes each local copy only after a successful upload. Failed uploads stay local for retry. Existing local copies recorded in the R2 storage index are also removed after inactivity without uploading them again. With R2 disabled, files stay local. Git carries every save. `data/assets-index.json` tracks known assets so duplicates are never re-uploaded. - -The first editor save uploads a compact MWP archive containing the Git repository without a duplicate worktree. Later saves compare the local HEAD with the server HEAD and send only new Git objects plus updated refs. The API stores those archives as content-addressed layers, so remixes share their parent's history instead of copying it. It compacts a chain after eight layers. If the user saves without making a commit, the editor sends a full archive with the worktree so uncommitted changes are not lost. - -For a delta whose `baseHead` still matches the stored head, the API removes loose Git objects already present in the materialized base before storing the new layer. It retains the delta manifest and refs, verifies the combined archive, and stitches only the new commits and graph nodes ahead of `baseHead` onto the inherited metadata. A final locked head comparison rejects concurrent history changes before project metadata is saved. - -## Storage limits - -Each plan has a total storage limit: 500 MB on Free, 2 GB on Lite, 10 GB on Plus and 50 GB on Pro. A user's usage is the sum of every project they own, trashed projects included. Each project counts its assets, its compressed project JSON and its history archive, even when an asset is shared with other projects. Saving is checked against the owner's limit before anything is stored. A save may make a project bigger only while it fits in the free space, so a user over the limit can still save a project at its current size or smaller. Assets uploaded ahead of a save count as pending until a saved project references them, or for 7 days. Deleting a project frees its space once it is removed from the trash. Backpack items count toward the same limit, and deleting one frees its space straight away. `GET /v1/me/quota` returns `used`, `pending`, `limit`, `remaining`, `backpack` (the bytes held in the backpack) and the five largest projects. On first start after this change, the server measures the history size of existing projects from local copies and the R2 inventory. - -## Backpack - -The editor backpack syncs to the signed-in account. Items are scripts, sprites, costumes and sounds, stored as a body file plus an optional PNG or JPEG thumbnail under `backpack//` in R2 (or `data/blobs/` locally), with the per-user list in `data/backpack/`. Blob URLs are unguessable and served sandboxed with `nosniff`; only the owner can list items. - -| Route | Purpose | -| --- | --- | -| `GET /v1/me/backpack?type=&offset=&limit=` | Newest first, up to 100 per page, with `total`, `count` and `bytes` | -| `POST /v1/me/backpack` | Multipart `type`, `mime`, `name`, `body`, and optional `thumbnail` with `thumbnailMime`; checked against the storage limit | -| `PATCH /v1/me/backpack/:id` | Rename with `{name}` | -| `DELETE /v1/me/backpack/:id` | Delete one item | -| `POST /v1/me/backpack/delete` | Delete several with `{ids}`; returns `deleted` and `freedBytes` | - -A body may be up to 20 MB and a backpack holds up to 2000 items. Account deletion, deleted Rotur accounts and data export all include the backpack. - -## Auth flow - -1. Client holds a Rotur token from Sign in with Rotur, with the `validators:generate` scope. -2. Client calls `POST https://api.rotur.dev/v2/validators` with `{"key": ""}` and the token in the `Authorization` header, never the URL. MistWarp's own token can always make validators for its app, without `validators:generate`. Older editors send `ROTUR_APP_KEY` instead. It must be the same Rotur instance the server validates against. -3. Client calls `POST /v1/auth?v=`; the API validates it against `https://api.rotur.dev/validate`, with the app ID first and then the old key, and returns a 7 day session token (also set as the auth_token cookie). Bearer header and cookie are both accepted. For the app ID, Rotur also refuses anyone the app has banned, or who otherwise can't use MistWarp, with a message for them. - -Only MistWarp's own sign-in token can make app-ID validators (rotur/api#85), so the desktop app, which still uses the old sign-in, and older tokens send the old key. Rotur doesn't check the old key against MistWarp's Rotur App, so for those sign-ins Rotur's suspension, adults-only, parental approval and age settings aren't enforced, and a rotur.dev ban only applies once the five-minute ban sync has brought it in. Old-key validators made by another Rotur App's sign-in are refused. The gap closes once the desktop app uses Sign in with Rotur through a loopback redirect, which needs Rotur to accept any port on `http://127.0.0.1`. - -A refusal from Rotur is answered with 403 and its `code`, `error`, and for an app ban its `reason` and `until`. - - -## Milestone notifications - -MistWarp sends one-time notifications at 5, 25, 50, 100, 500, and 1,000 likes or followers. Likes are counted separately for each project, comment, roadmap post, space, news post, profile post, and theme. The `like_milestones` collection stores progress independently of the inbox, so clearing notifications, unliking, and re-liking do not reset it. - -Project and community reactions check milestones when saved. Profile-post, theme, and follower checks read existing public APIs and keep their state in MistWarp. The client requests a check after a like or follow; opening the inbox also checks the current account's followers. These sampled counts can detect a threshold crossed between visits, but cannot detect a count that rose and fell entirely between checks. Existing counts establish a baseline without sending every earlier milestone. No Rotur or WarpTheme server changes are required. - -## Project source access +The code is split by domain under `src/`, and `main.osl` composes them. -Projects with See inside disabled expose commits, branches, files, and workspace archives only to their owner. Pull-request inspection follows the same restriction. The project response exposes `canViewSource` for clients. +## Documentation -Deploy the API and client changes together. Purge previously cached project history and workspace responses from the CDN when deploying this policy; new responses use `private, no-store`. This policy controls MistWarp's inspection endpoints. Playback still sends the data required to run a Scratch project to the browser. +The [Backend section of the MistWarp docs](https://mistwarp.org/docs/backend/overview) covers the source layout, configuration, sign-in, safety and moderation, payments, storage, and community features. diff --git a/main.osl b/main.osl index 4d75e8c..a06b1ff 100644 --- a/main.osl +++ b/main.osl @@ -21,289 +21,20 @@ import "go:mistwarp.local/r2inventory" import "go:mistwarp.local/storagefs" import "go:mistwarp.local/sharecard" -def dotenvValue(string name) string ( - if !fs.exists(".env") ( - return "" - ) - string contents = fs.readFile(".env") catch (err) ( - log "WARNING: could not read .env: " ++ err.toStr() - return "" - ) - array lines = contents.split("\n") - for i lines.len ( - string line = lines[i].toStr().strip() - if line.startsWith("export ") ( - line = line.replace("export ", "").strip() - ) - if line != "" and !line.startsWith("#") and line.contains("=") ( - array parts = line.split("=") - string key = parts[1].toStr().strip() - if key == name ( - string value = parts[2].toStr().strip() - for p parts.len ( - if p > 2 ( - value ++= "=" ++ parts[p].toStr() - ) - ) - return value.replace("\"", "").replace("'", "") - ) - ) - ) - return "" -) - -def envValue(string name) string ( - string value = env.get(name, "") catch (err) ( - log "WARNING: could not read " ++ name ++ " from the environment: " ++ err.toStr() - return "" - ) - return value -) - -// Configuration is looked up in this order: -// 1. MISTWARP_ in the real environment (an explicit, app-specific override) -// 2. this app's .env -// 3. in the real environment (for deployments that don't ship a .env) -// -// Inherited variables come last on purpose. The API is often started from a -// shared shell that exports another project's variables (for example Rotur's -// R2_ACCESS_KEY_ID and R2_PUBLIC_BASE), and those must not silently replace -// this service's own configuration. -def configValue(string name, string defaultValue) string ( - string value = envValue("MISTWARP_" ++ name) - if value != "" ( - return value - ) - value = dotenvValue(name) - if value != "" ( - return value - ) - value = envValue(name) - if value != "" ( - return value - ) - return defaultValue -) - -string projectsDir = "data/projects/" -string indexFile = "data/projects-index.json" -string assetsIndexFile = "data/assets-index.json" -string usersDir = "data/users/" -string sessionsDir = "data/sessions/" -string commentsDir = "data/comments/" -string reportsDir = "data/reports/" -string reportsFile = reportsDir ++ "reports.json" -string reportEvidenceDir = "data/report-evidence/" -string newsFile = "data/news.json" -string notificationsDir = "data/notifications/" -string activityDir = "data/activity/" -string userSettingsDir = "data/usersettings/" -string tmpDir = "data/tmp/" -string stagingDir = "data/staging/" -string thumbnailsDir = "data/thumbnails/" -string cardsDir = "data/cards/" -string thumbnailSecretFile = "data/thumbnail-secret" -string quotaDir = "data/quota/" -string userIdsFile = "data/userids.json" -string adminsFile = "data/admins.json" -string bansFile = "data/bans.json" -string shadowBansFile = "data/shadowbans.json" -string agreementAcceptDir = "data/agreement-acceptances/" -string extensionsDir = "data/extensions/" -string releasesDir = "data/releases/" -string extensionsFile = "data/extensions.json" -string safetyDir = "data/safety/" -string trashDir = "data/trash/" -string gameSavesDir = "data/game-saves/" -string gameSaveUsageDir = "data/game-save-usage/" -string legacyAccountGameDataDir = "data/account-game-data/" -string gameEntitlementsDir = "data/game-entitlements/" -string gameInventoryDir = "data/game-inventory/" -string backpackDir = "data/backpack/" - -number maxWorkspaceBytes = 536870912 -number maxWorkspaceFiles = 1000000 -number maxUploadChunkBytes = 8388608 - -number maxProjectJsonBytes = 268435456 -number maxStoredProjectJsonBytes = 20971520 -number maxThumbBytes = 1048576 -number maxAssetCount = 2000 -number quotaWindowMs = 604800000 -number uploadDebounceMs = 604800000 -number maxProjectExtensions = 100 -number maxExtensionSourceBytes = 2097152 -number maxProjectExtensionBytes = 10485760 - -string appURL = configValue("APP_URL", "https://api.mistwarp.org") -string port = configValue("PORT", "5627") -string roturAppKey = configValue("ROTUR_APP_KEY", "mistwarp") -string r2Endpoint = configValue("R2_ENDPOINT", "") -string r2Bucket = configValue("R2_BUCKET", "mistwarp") -string r2AccessKeyId = configValue("R2_ACCESS_KEY_ID", "") -string r2SecretAccessKey = configValue("R2_SECRET_ACCESS_KEY", "") -string r2PublicBase = configValue("R2_PUBLIC_BASE", "") -string giteaURL = configValue("GITEA_URL", "https://git.rotur.dev") -string giteaAdminToken = configValue("GITEA_ADMIN_TOKEN", "") -string githubOrg = configValue("GITHUB_ORG", "MistWarp") -string githubToken = configValue("GITHUB_TOKEN", "") -string editorOrigin = configValue("EDITOR_ORIGIN", "https://mistwarp.org") -array adminUsers = configValue("ADMIN_USERS", "mist").toLower().split(",") -string mistwarpRoturToken = configValue("MISTWARP_ROTUR_TOKEN", "") -string mistwarpRoturUser = configValue("MISTWARP_ROTUR_USER", "") -string roturBase = configValue("ROTUR_BASE", "https://api.rotur.dev") -string commerceServiceKey = configValue("COMMERCE_SERVICE_KEY", "") -string stripeSecretKey = configValue("STRIPE_SECRET_KEY", "") -string stripeWebhookSecret = configValue("STRIPE_WEBHOOK_SECRET", "") -string stripeClassroomPrice = configValue("STRIPE_CLASSROOM_PRICE", "") -string stripeSeatPackPrice = configValue("STRIPE_SEAT_PACK_PRICE", "") -string stripeClassroomPriceYearly = configValue("STRIPE_CLASSROOM_PRICE_YEARLY", "") -string stripeSeatPackPriceYearly = configValue("STRIPE_SEAT_PACK_PRICE_YEARLY", "") -string realtimeURL = configValue("REALTIME_URL", "wss://api.mistwarp.org/v1/connect") -number historyMigrationWorkers = configValue("HISTORY_MIGRATION_WORKERS", "4").toNum().toInt() -if historyMigrationWorkers < 1 ( - historyMigrationWorkers = 1 -) -if historyMigrationWorkers > 8 ( - historyMigrationWorkers = 8 -) -string purchaseKeysFile = "data/purchase-keys.json" - -boolean r2Local = r2Endpoint == "" or r2AccessKeyId == "" -string localBlobsDir = "data/blobs/" - -auto r2Client = s3.new({ - "endpoint": if r2Local then "http://127.0.0.1:9" else r2Endpoint, - "bucket": r2Bucket, - "access_key_id": r2AccessKeyId, - "secret_access_key": r2SecretAccessKey, - "region": "auto" -}) - -void fs.mkdir("data/", {recursive: true}) - -import "./utils.osl" -import "./request-body.osl" -import "./search-ranking.osl" -import "./request-limits.osl" -import "./project-sharing-policy.osl" -import "./archive-security.osl" -import "./storage-addressing.osl" -import "./storage.osl" -import "./database.osl" -import "./user-identity.osl" -import "./asset-routing.osl" -import "./thumbnail-access.osl" -import "./r2.osl" -import "./r2-upload-queue.osl" -import "./cache-policy.osl" -import "./git-inspection.osl" -import "./gitea.osl" -import "./rotur-app.osl" -import "./rotur-payments.osl" -import "./rotur-signals.osl" -import "./rotur-badges.osl" -import "./admin-user-policy.osl" -import "./admin-users.osl" -import "./rotur-plan.osl" -import "./auth.osl" -import "./middleware.osl" -import "./upgrade-policy.osl" -import "./perks.osl" -import "./vanity-policy.osl" -import "./quota-policy.osl" -import "./storage-ledger-policy.osl" -import "./storage-ledger.osl" -import "./groups.osl" -import "./banned-owners.osl" -import "./related-projects.osl" -import "./random-projects.osl" -import "./projects.osl" -import "./storage-usage.osl" -import "./live-collaboration.osl" -import "./history-delta.osl" -import "./commit-management.osl" -import "./game-data.osl" -import "./game-realtime.osl" -import "./extensions.osl" -import "./monetize.osl" -import "./game-inventory.osl" -import "./backpack-policy.osl" -import "./backpack.osl" -import "./game-marketplace.osl" -import "./report-evidence.osl" -import "./comments.osl" -import "./bounties.osl" -import "./social.osl" -import "./pull-metadata.osl" -import "./pulls.osl" -import "./reports.osl" -import "./errors.osl" -array errorReportOrigins = [] -for entry in (editorOrigin ++ "," ++ configValue("ERROR_REPORT_ORIGINS", "")).split(",") ( - string normalized = siteErrorOrigin(entry, "") - if normalized != "" ( - errorReportOrigins.append(normalized) - ) -) -import "./admin.osl" -import "./funnel-events-policy.osl" -import "./funnel-events.osl" -import "./project-moderation.osl" -import "./like-milestone-policy.osl" -import "./like-milestones.osl" -import "./notifications.osl" -import "./remote-milestones.osl" -import "./news.osl" -import "./development-links.osl" -import "./development.osl" -import "./standing.osl" -import "./settings.osl" -import "./agreement.osl" -import "./account-controls.osl" -import "./rotur-webhooks.osl" -import "./challenge-ranking.osl" -import "./community-tools.osl" -import "./classroom-policy.osl" -import "./release-access-policy.osl" -import "./editor-session-policy.osl" -import "./privacy-policy.osl" -import "./classrooms.osl" -import "./classroom-billing-policy.osl" -import "./classroom-billing.osl" -import "./classroom-groups.osl" -import "./classroom-release.osl" -import "./classroom-schools.osl" -import "./classroom-present.osl" -import "./classroom-legal-policy.osl" -import "./classroom-legal.osl" -import "./classroom-switch-policy.osl" -import "./classroom-switch.osl" -import "./site-realtime.osl" -import "./history-migration.osl" - -auto remoteMilestoneChecks = cache.new({capacity: 10000, ttl: 10000}) -auto viewDedup = cache.new({capacity: 100000, ttl: 21600000}) -auto roturAppOwnerCache = cache.new({capacity: 4, ttl: 3600000}) -auto viewIndexFlush = cache.new({capacity: 100000, ttl: 600000}) -auto userIdsCache = cache.new({capacity: 10000, ttl: 300000}) -auto identityRefreshCache = cache.new({capacity: 10000, ttl: 300000}) -auto sessionCache = cache.new({capacity: 10000, ttl: 3600000}) -auto bansCache = cache.new({capacity: 2, ttl: 300000}) -auto shadowBansCache = cache.new({capacity: 2, ttl: 300000}) -auto adminsCache = cache.new({capacity: 2, ttl: 300000}) -auto sharedProjectsCache = cache.new({capacity: 2, ttl: 60000}) -auto gitInspectionPruneCache = cache.new({capacity: 4, ttl: 300000}) -auto paidPerksCache = cache.new({capacity: 10000, ttl: 300000}) -auto lastKnownTiers = cache.new({capacity: 10000, ttl: 2592000000}) -auto roturTierUnreachable = cache.new({capacity: 10000, ttl: 60000}) -auto roturMessageSignals = cache.new({capacity: 10000, ttl: 60000}) -auto groupSnapshotCache = cache.new({capacity: 10000, ttl: 60000}) -auto gameDataCapabilityCache = cache.new({capacity: 100000, ttl: 300000}) -auto multiplayerTicketCache = cache.new({capacity: 100000, ttl: 120000}) -auto realtimeHandshakeCache = cache.new({capacity: 100000, ttl: 30000}) -auto developmentCache = cache.new({capacity: 4, ttl: 300000}) -auto classroomPoolCache = cache.new({capacity: 10000, ttl: 60000}) +import "./src/config" +import "./src/core" +import "./src/http" +import "./src/storage" +import "./src/git" +import "./src/rotur" +import "./src/auth" +import "./src/projects" +import "./src/history" +import "./src/games" +import "./src/realtime" +import "./src/community" +import "./src/admin" +import "./src/classroom" ensureDirectories() string thumbnailSecret = loadThumbnailSecret(thumbnailSecretFile) @@ -400,15 +131,7 @@ app.use(realtimeUpgradeGuard) app.use(serve.corsOpen()) app.use(serve.logger()) -app.GET("/thumbnails/:file", handleGetThumbnail) -app.GET("/blobs/assets/:name", handleGetAssetBlob) -app.GET("/blobs/backpack/:folder/:file", handleGetBackpackBlob) -app.GET("/blobs/projects/:id/project.json", handleGetProjectBlob) -app.GET("/blobs/evidence/:id/project.json", handleGetEvidenceJson) -app.GET("/blobs/evidence/:id/assets/:name", handleGetEvidenceAsset) -if r2Local ( - app.static("/blobs", "./data/blobs") -) +registerBlobRoutes(app) app.use(authMiddleware) app.use(editorSessionGuard) @@ -418,335 +141,24 @@ setupGameRealtime() setupSiteRealtime() def setupUploadRoutes(*serve.Router uploads) ( - uploads.POST("/projects/:id/upload", serve.maxBodySize(maxProjectUploadRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProject) - uploads.POST("/projects/:id/upload-parts/:token/chunk", serve.maxBodySize(maxUploadChunkRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProjectPartChunk) - uploads.POST("/projects/:id/history/bootstrap", serve.maxBodySize(maxWorkspaceBootstrapRequestBytes), requireAuth, requireCurrentAgreement, handleBootstrapProjectHistory) - uploads.POST("/projects/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetThumbnail) - uploads.POST("/me/backpack", serve.maxBodySize(maxBackpackRequestBytes), requireAuth, requireCurrentAgreement, handleCreateBackpackItem) - uploads.POST("/spaces/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetSpaceThumbnail) - uploads.POST("/admin/projects/:id/extensions/index", serve.maxBodySize(maxExtensionIndexRequestBytes), requireAdmin, handleAdminIndexProjectExtensions) + registerProjectUploadRoutes(uploads) + registerStorageUploadRoutes(uploads) + registerCommunityUploadRoutes(uploads) ) def setupApiRoutes(*serve.Router api) ( api.use(serve.maxBodySize(maxStandardRequestBytes)) - api.GET("/health", handleHealth) - api.GET("/health/dependencies", handleDependencyHealth) - api.GET("/perks", handleGetPerks) - api.POST("/perks/refresh", requireAuth, handleRefreshPerks) - api.POST("/perks/seen", requireAuth, handlePerksSeen) - api.POST("/auth", handleRoturAuth) - api.POST("/auth/editor-session", serve.limitRequests(600, {window: 3600000}), requireAuth, handleCreateEditorSession) - api.GET("/me", handleGetMe) - api.POST("/logout", handleLogout) - - api.POST("/milestones/followers/:name", requireAuth, handleCheckFollowerMilestones) - api.POST("/milestones/posts/:id", requireAuth, handleCheckPostMilestones) - api.POST("/milestones/themes/:id", requireAuth, handleCheckThemeMilestones) - api.GET("/notifications", requireAuth, handleGetNotifications) - api.POST("/notifications/read", requireAuth, handleReadNotifications) - - api.GET("/me/quota", requireAuth, handleGetMyQuota) - api.GET("/me/stats", requireAuth, handleGetMyStats) - api.GET("/me/library", requireAuth, handleGetMyLibrary) - api.GET("/me/shared-projects", requireAuth, handleGetSharedProjects) - api.PUT("/me/library/:project/visibility", requireAuth, handleSetLibraryProjectVisibility) - api.GET("/me/trash", requireAuth, handleGetMyTrash) - api.GET("/me/purchases", requireAuth, handleGetMyPurchases) - api.GET("/me/settings", requireAuth, handleGetMySettings) - api.PUT("/me/settings", requireAuth, handlePutMySettings) - api.GET("/me/game-saves", requireAuth, handleListMyGameSaves) - api.GET("/me/game-inventory", requireAuth, handleGetMyGameInventory) - api.GET("/me/backpack", requireAuth, handleGetMyBackpack) - api.PATCH("/me/backpack/:id", requireAuth, handleUpdateBackpackItem) - api.DELETE("/me/backpack/:id", requireAuth, handleDeleteBackpackItem) - api.POST("/me/backpack/delete", requireAuth, handleDeleteBackpackItems) - api.DELETE("/me/game-saves/:project", requireAuth, handleDeleteMyGameSave) - api.GET("/me/export", requireAuth, handleExportMyData) - api.DELETE("/me/data", requireAuth, handleDeleteMyData) - api.POST("/rotur/webhook", handleRoturWebhook) - api.GET("/me/safety", requireAuth, handleGetMySafety) - api.POST("/me/blocks/:name", requireAuth, handleBlockUser) - api.DELETE("/me/blocks/:name", requireAuth, handleUnblockUser) - api.POST("/me/mutes/:name", requireAuth, handleMuteUser) - api.DELETE("/me/mutes/:name", requireAuth, handleUnmuteUser) - - api.GET("/agreement", handleGetAgreement) - api.POST("/agreement/accept", requireAuth, handleAcceptAgreement) - - api.GET("/explore", handleExplore) - api.GET("/vanity/:slug", handleResolveVanityProject) - api.GET("/groups/:tag", handleGetMistWarpGroup) - api.GET("/leaderboard", handleLeaderboard) - api.GET("/development/pulls", handleDevelopmentPulls) - api.GET("/roadmap", handleListIdeas) - api.POST("/roadmap", requireAuth, requireGoodStanding, handleCreateIdea) - api.POST("/roadmap/:id/vote", requireAuth, handleVoteIdea) - api.PUT("/roadmap/:id", requireAdmin, handleUpdateIdea) - api.GET("/roadmap/:id/comments", handleGetIdeaComments) - api.POST("/roadmap/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateIdeaComment) - api.DELETE("/roadmap/:id/comments/:cid", requireAuth, handleDeleteIdeaComment) - api.PUT("/roadmap/:id/comments/:cid", requireAuth, handleEditIdeaComment) - api.POST("/roadmap/:id/comments/:cid/react", requireAuth, handleReactIdeaComment) - api.GET("/spaces", handleListSpaces) - api.POST("/spaces", requireAuth, requireGoodStanding, handleCreateSpace) - api.GET("/spaces/:id", handleGetSpace) - api.GET("/spaces/:id/manage", requireAuth, handleGetSpaceManagement) - api.PUT("/spaces/:id", requireAuth, handleUpdateSpace) - api.DELETE("/spaces/:id", requireAuth, handleDeleteSpace) - api.POST("/spaces/:id/projects/:project", requireAuth, handleAddSpaceProject) - api.DELETE("/spaces/:id/projects/:project", requireAuth, handleRemoveSpaceProject) - api.POST("/spaces/:id/follow", requireAuth, handleFollowSpace) - api.DELETE("/spaces/:id/follow", requireAuth, handleUnfollowSpace) - api.POST("/spaces/:id/curators", requireAuth, handleInviteSpaceCurator) - api.DELETE("/spaces/:id/curators/:username", requireAuth, handleRemoveSpaceCurator) - api.POST("/spaces/:id/invitations/respond", requireAuth, handleRespondSpaceCuratorInvite) - api.DELETE("/spaces/:id/invitations/:username", requireAuth, handleCancelSpaceCuratorInvite) - api.POST("/spaces/:id/judges", requireAuth, handleInviteChallengeJudge) - api.DELETE("/spaces/:id/judges/:username", requireAuth, handleRemoveChallengeJudge) - api.POST("/spaces/:id/judge-invitations/respond", requireAuth, handleRespondChallengeJudgeInvite) - api.POST("/spaces/:id/join", requireAuth, requireGoodStanding, handleJoinChallenge) - api.DELETE("/spaces/:id/join", requireAuth, handleLeaveChallenge) - api.PUT("/spaces/:id/entries/:project/score", requireAuth, handleScoreChallengeEntry) - api.PUT("/spaces/:id/entries/:project/vote", requireAuth, handleVoteChallengeEntry) - api.POST("/spaces/:id/results", requireAuth, handlePublishChallengeResults) - api.POST("/spaces/:id/react", requireAuth, handleReactSpace) - api.GET("/spaces/:id/comments", handleGetSpaceComments) - api.POST("/spaces/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateSpaceComment) - api.DELETE("/spaces/:id/comments/:cid", requireAuth, handleDeleteSpaceComment) - api.PUT("/spaces/:id/comments/:cid", requireAuth, handleEditSpaceComment) - api.POST("/spaces/:id/comments/:cid/react", requireAuth, handleReactSpaceComment) - api.POST("/spaces/:id/comments/:cid/pin", requireAuth, handlePinSpaceComment) - api.GET("/me/spaces", requireAuth, handleMySpaces) - - api.GET("/classroom/join/:code", serve.limitRequests(30, {window: 60000}), handleClassroomJoinInfo) - api.POST("/classroom/login", serve.limitRequests(20, {window: 60000}), handleClassroomLogin) - api.GET("/classroom/available", handleClassroomAvailable) - api.GET("/classroom", requireAuth, handleGetClassroom) - api.POST("/classroom/terms/accept", requireClassroomTeacher, serve.limitRequests(20, {window: 3600000}), handleAcceptClassroomTerms) - api.GET("/classroom/school", requireClassroomTeacher, handleGetSchool) - api.GET("/classroom/school/audit", requireClassroomTeacher, handleGetSchoolAudit) - api.GET("/classroom/school/export", requireClassroomTeacher, serve.limitRequests(10, {window: 3600000}), handleExportSchool) - api.POST("/classroom/school/teachers", requireClassroomTeacher, requireClassroomTerms, handleAddSchoolTeacher) - api.DELETE("/classroom/school/teachers/:teacher", requireClassroomTeacher, handleRemoveSchoolTeacher) - api.PUT("/classroom/school/teachers/:teacher/admin", requireClassroomTeacher, handleSetSchoolAdmin) - api.POST("/classroom/billing/checkout", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(20, {window: 3600000}), handleClassroomCheckout) - api.POST("/classroom/billing/portal", requireClassroomTeacher, serve.limitRequests(30, {window: 3600000}), handleClassroomBillingPortal) - api.POST("/classroom/billing/webhook", handleStripeWebhook) - api.POST("/classroom/trial", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(10, {window: 3600000}), handleStartClassroomTrial) - api.POST("/classroom/classes", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(20, {window: 3600000}), handleCreateClass) - api.GET("/classroom/classes/:id", requireClassroomTeacher, handleGetClass) - api.PUT("/classroom/classes/:id", requireClassroomTeacher, handleUpdateClass) - api.DELETE("/classroom/classes/:id", requireClassroomTeacher, handleDeleteClass) - api.POST("/classroom/classes/:id/code", requireClassroomTeacher, handleResetClassCode) - api.POST("/classroom/classes/:id/teachers", requireClassroomTeacher, requireClassroomTerms, handleAddCoTeacher) - api.DELETE("/classroom/classes/:id/teachers/:teacher", requireClassroomTeacher, handleRemoveCoTeacher) - api.POST("/classroom/classes/:id/transfer", requireClassroomTeacher, handleTransferClass) - api.GET("/classroom/classes/:id/audit", requireClassroomTeacher, handleGetClassAudit) - api.GET("/classroom/classes/:id/export", requireClassroomTeacher, serve.limitRequests(30, {window: 3600000}), handleExportClass) - api.GET("/classroom/classes/:id/groups", requireClassroomTeacher, handleListClassGroups) - api.POST("/classroom/classes/:id/present", requireClassroomTeacher, requireClassroomTerms, handleStartPresentation) - api.DELETE("/classroom/classes/:id/present", requireClassroomTeacher, handleStopPresentation) - api.POST("/classroom/classes/:id/groups", requireClassroomTeacher, requireClassroomTerms, handleCreateClassGroup) - api.DELETE("/classroom/classes/:id/groups/:gid", requireClassroomTeacher, handleDeleteClassGroup) - api.POST("/classroom/classes/:id/students", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(30, {window: 3600000}), handleCreateStudents) - api.GET("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleGetClassStudent) - api.GET("/classroom/classes/:id/students/:sid/export", requireClassroomTeacher, serve.limitRequests(60, {window: 3600000}), handleExportClassStudent) - api.PUT("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleUpdateClassStudent) - api.DELETE("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleDeleteClassStudent) - api.POST("/classroom/classes/:id/students/:sid/reset", requireClassroomTeacher, handleResetStudentLogin) - api.POST("/classroom/classes/:id/students/:sid/move", requireClassroomTeacher, handleMoveClassStudent) - api.POST("/classroom/classes/:id/students/:sid/release", requireClassroomTeacher, requireClassroomTerms, handleReleaseStudent) - api.DELETE("/classroom/classes/:id/students/:sid/release", requireClassroomTeacher, handleCancelStudentRelease) - api.GET("/classroom/claim/:code", serve.limitRequests(30, {window: 60000}), handleGetClaim) - api.POST("/classroom/claim/:code", requireAuth, serve.limitRequests(10, {window: 60000}), handleRedeemClaim) - api.POST("/classroom/classes/:id/assignments", requireClassroomTeacher, requireClassroomTerms, handleCreateAssignment) - api.GET("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleGetAssignment) - api.PUT("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleUpdateAssignment) - api.DELETE("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleDeleteAssignment) - api.PUT("/classroom/classes/:id/assignments/:aid/submissions/:sid", requireClassroomTeacher, handleReviewSubmission) - api.POST("/classroom/assignments/:aid/start", requireAuth, handleStartAssignment) - api.POST("/classroom/assignments/:aid/turn-in", requireAuth, handleTurnInAssignment) - api.POST("/classroom/assignments/:aid/unsubmit", requireAuth, handleUnsubmitAssignment) - - api.POST("/projects", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreateProject) - api.POST("/projects/:id/live", requireAuth, serve.limitRequests(120, {window: 60000}), handleLiveCollaboration) - api.POST("/projects/:id/assets/check", requireAuth, handleCheckProjectAssets) - api.POST("/projects/:id/upload-parts", requireAuth, requireCurrentAgreement, handleCreateProjectUploadPart) - api.POST("/projects/:id/upload-parts/:token/complete", requireAuth, requireCurrentAgreement, handleCompleteProjectUploadPart) - api.GET("/projects/random", handleRandomProject) - api.GET("/projects/featured", handleFeaturedProject) - api.GET("/projects/:id/related", handleRelatedProjects) - api.GET("/sitemap.xml", handleSitemap) - api.GET("/projects/:id/project.json", handleGetProjectJson) - api.GET("/projects/:id/trusted-extensions", handleGetProjectTrustedExtensions) - api.GET("/projects/:id/workspace.mwp", handleGetProjectWorkspace) - api.GET("/projects/:id/extensions/:hash/source", handleGetProjectExtensionSource) - api.GET("/projects/:id/editor", handleGetEditorProject) - api.GET("/projects/:id", handleGetProject) - api.POST("/projects/:id/data-capability", requireAuth, handleCreateGameDataCapability) - api.POST("/projects/:id/multiplayer-ticket", requireAuth, handleCreateMultiplayerTicket) - api.GET("/projects/:id/products", handleListGameProducts) - api.GET("/projects/:id/products/:product/owns", requireAuth, handleOwnsGameProduct) - api.POST("/projects/:id/products/:product/purchase/intent", requireAuth, handleGameProductIntent) - api.POST("/projects/:id/products/:product/purchase/confirm", requireAuth, handleGameProductConfirm) - api.POST("/projects/:id/products/:product/grant", requireAuth, handleGrantGameProduct) - api.POST("/projects/:id/products/:product/revoke", requireAuth, handleRevokeGameProduct) - api.POST("/projects/:id/products/:product/refund/intent", requireAuth, handleGameProductRefundIntent) - api.GET("/projects/:id/game-inventory-config", requireAuth, handleGetGameInventoryConfig) - api.GET("/projects/:id/me/inventory", requireAuth, handleGetProjectInventory) - api.POST("/projects/:id/me/inventory/grant", requireAuth, handleGrantProjectItem) - api.GET("/projects/:id/me/save", requireAuth, handleGetMyProjectSave) - api.PUT("/projects/:id/me/save", requireAuth, handlePutMyProjectSave) - api.GET("/projects/:id/card.svg", handleProjectCard) - api.GET("/projects/:id/card.png", handleProjectSharePng) - api.PUT("/projects/:id", requireAuth, handleUpdateProject) - api.DELETE("/projects/:id", requireAuth, handleDeleteProject) - api.POST("/projects/:id/restore", requireAuth, handleRestoreProject) - api.DELETE("/trash/:id", requireAuth, handlePurgeTrashedProject) - api.POST("/projects/:id/publish", requireAuth, requireGoodStanding, handlePublishProject) - api.POST("/projects/:id/unpublish", requireAuth, handleUnpublishProject) - api.POST("/projects/:id/visibility", requireAuth, requireGoodStanding, handleSetVisibility) - api.POST("/projects/:id/purchase/intent", handleProjectSalesEnded) - api.POST("/projects/:id/purchase/confirm", handleProjectSalesEnded) - api.POST("/projects/:id/view", handleViewProject) - api.POST("/projects/:id/save", requireAuth, handleSaveProject) - api.DELETE("/projects/:id/save", requireAuth, handleUnsaveProject) - api.POST("/projects/:id/react", requireAuth, handleReactProject) - api.GET("/projects/:id/remixes", handleGetRemixes) - api.GET("/projects/:id/remixtree", handleGetRemixTree) - api.POST("/projects/:id/remix", requireAuth, serve.limitRequests(30, {window: 3600000}), handleRemixProject) - api.POST("/projects/:id/preview", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreatePreview) - api.GET("/projects/:id/releases", handleListReleases) - api.GET("/projects/:id/releases/:release/project.json", handleGetReleaseProjectJson) - api.POST("/projects/:id/releases", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreateRelease) - api.DELETE("/projects/:id/releases/:release", requireAuth, handleDeleteRelease) - api.POST("/projects/:id/contribute", requireAuth, handleCreateContribution) - api.POST("/projects/:id/diagnostics", serve.limitRequests(120, {window: 60000}), handleRecordDiagnostic) - api.GET("/projects/:id/diagnostics", requireAuth, handleGetDiagnostics) - api.POST("/projects/:id/feedback", requireAuth, requireGoodStanding, serve.limitRequests(20, {window: 3600000}), handleCreateProjectFeedback) - api.GET("/projects/:id/feedback", requireAuth, handleGetProjectFeedback) - api.PUT("/projects/:id/feedback/:feedback", requireAuth, handleUpdateProjectFeedback) - api.GET("/projects/:id/reviews", handleListProjectReviews) - api.PUT("/projects/:id/reviews/me", requireAuth, requireGoodStanding, handleSaveProjectReview) - api.DELETE("/projects/:id/reviews/me", requireAuth, handleDeleteProjectReview) - - api.GET("/projects/:id/comments", handleGetProjectComments) - api.POST("/projects/:id/comments/donation/intent", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleProjectCommentDonationIntent) - api.POST("/projects/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateProjectComment) - api.DELETE("/projects/:id/comments/:cid", requireAuth, handleDeleteProjectComment) - api.PUT("/projects/:id/comments/:cid", requireAuth, handleEditProjectComment) - api.POST("/projects/:id/comments/:cid/react", requireAuth, handleReactProjectComment) - api.POST("/projects/:id/comments/:cid/pin", requireAuth, handlePinProjectComment) - - api.GET("/bounties/:id/activity", handleGetBountyActivity) - api.POST("/bounties/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateBountyComment) - api.PUT("/bounties/:id/comments/:cid", requireAuth, handleEditBountyComment) - api.DELETE("/bounties/:id/comments/:cid", requireAuth, handleDeleteBountyComment) - api.POST("/bounties/:id/workers/me", requireAuth, requireGoodStanding, handleJoinBounty) - api.DELETE("/bounties/:id/workers/me", requireAuth, handleLeaveBounty) - - api.GET("/projects/:id/commits", handleGetCommits) - api.GET("/projects/:id/branches", handleListProjectBranches) - api.POST("/projects/:id/branches", requireAuth, handleCreateProjectBranch) - api.PATCH("/projects/:id/branches", requireAuth, handleRenameProjectBranch) - api.DELETE("/projects/:id/branches", requireAuth, handleDeleteProjectBranch) - api.POST("/projects/:id/branches/merge", requireAuth, handlePrepareBranchMerge) - api.GET("/projects/:id/commits/:sha", handleGetCommitInspection) - api.PATCH("/projects/:id/commits/:sha", requireAuth, handlePatchCommit) - api.GET("/projects/:id/commits/:sha/co-authors", handleGetCommitCollaborators) - api.GET("/projects/:id/commits/:sha/collaborators", handleGetCommitCollaborators) - api.GET("/projects/:id/commits/:sha/tree", handleGetCommitTree) - api.GET("/projects/:id/commits/:sha/file", handleGetCommitFile) - api.GET("/projects/:id/pulls", handleListPulls) - api.POST("/projects/:id/pulls", requireAuth, handleCreatePull) - api.GET("/projects/:id/pulls/:index", handleGetPull) - api.GET("/projects/:id/pulls/:index/timeline", handleGetPullTimeline) - api.POST("/projects/:id/pulls/:index/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreatePullComment) - api.PUT("/projects/:id/pulls/:index/comments/:cid", requireAuth, handleEditPullComment) - api.DELETE("/projects/:id/pulls/:index/comments/:cid", requireAuth, handleDeletePullComment) - api.GET("/projects/:id/pulls/:index/diff", requireAuth, handleGetPullDiff) - api.POST("/projects/:id/pulls/:index/merge", requireAuth, handleMergePull) - api.POST("/projects/:id/pulls/:index/close", requireAuth, handleClosePull) - - api.GET("/search/users", handleSearchUsers) - api.GET("/users/:name", handleGetUser) - api.GET("/users/:name/library", handleGetUserPlaytimeLibrary) - api.GET("/users/:name/reviews", handleListUserReviews) - api.PUT("/me/profile", requireAuth, handleUpdateProfile) - api.POST("/users/:name/follow", requireAuth, handleFollowUser) - api.DELETE("/users/:name/follow", requireAuth, handleUnfollowUser) - api.GET("/users/:name/projects", handleGetUserProjects) - api.GET("/activity", handleGetActivity) - api.GET("/users/:name/followers", handleGetUserFollowers) - api.GET("/users/:name/following", handleGetUserFollowing) - api.GET("/users/:name/comments", handleGetProfileComments) - api.POST("/users/:name/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateProfileComment) - api.DELETE("/users/:name/comments/:cid", requireAuth, handleDeleteProfileComment) - api.PUT("/users/:name/comments/:cid", requireAuth, handleEditProfileComment) - api.POST("/users/:name/comments/:cid/react", requireAuth, handleReactProfileComment) - api.POST("/users/:name/comments/:cid/pin", requireAuth, handlePinProfileComment) - - api.GET("/news", handleGetNews) - api.GET("/news/:id", handleGetNewsItem) - api.POST("/news/:id/view", serve.limitRequests(120, {window: 3600000}), handleViewNews) - api.POST("/news", requireAdmin, handleCreateNews) - api.PUT("/news/:id", requireAdmin, handleUpdateNews) - api.DELETE("/news/:id", requireAdmin, handleDeleteNews) - api.POST("/news/:id/react", requireAuth, handleReactNews) - api.POST("/news/:id/poll", requireAuth, handleVoteNewsPoll) - - api.POST("/report", requireAuth, serve.limitRequests(10, {window: 3600000}), handleCreateReport) - api.POST("/reports", requireAuth, serve.limitRequests(10, {window: 3600000}), handleCreateReport) - api.POST("/support", serve.limitRequests(5, {window: 3600000}), handleCreateSupportRequest) - api.POST("/errors", serve.limitRequests(20, {window: 60000}), handleRecordSiteError) - api.GET("/stats", handlePublicStats) - api.POST("/events", serve.limitRequests(120, {window: 60000}), handleRecordFunnelEvent) - - api.GET("/admin/reports", requireAdmin, handleGetReports) - api.GET("/admin/reports/evidence/:id", requireAdmin, handleGetReportEvidence) - api.POST("/admin/reports/resolve", requireAdmin, handleResolveReport) - api.POST("/admin/reports/action", requireAdmin, handleReportAction) - api.POST("/admin/reports/escalate", requireAdmin, handleEscalateReport) - api.GET("/admin/admins", requireAdmin, handleListAdmins) - api.POST("/admin/admins", requireAdmin, handleAddAdmin) - api.POST("/admin/admins/remove", requireAdmin, handleRemoveAdmin) - api.GET("/admin/bans", requireAdmin, handleListBans) - api.POST("/admin/ban", requireAdmin, handleBanUser) - api.POST("/admin/unban", requireAdmin, handleUnbanUser) - api.GET("/admin/shadowbans", requireAdmin, handleListShadowBans) - api.POST("/admin/shadowban", requireAdmin, handleShadowBanUser) - api.POST("/admin/unshadowban", requireAdmin, handleLiftShadowBan) - api.POST("/admin/projects/:id/hide", requireAdmin, handleHideProject) - api.POST("/admin/projects/:id/restore", requireAdmin, handleRestoreHiddenProject) - api.POST("/admin/projects/:id/shadowban", requireAdmin, handleShadowBanProject) - api.POST("/admin/projects/:id/unshadowban", requireAdmin, handleLiftProjectShadowBan) - api.POST("/admin/standing", requireAdmin, handleSetStanding) - api.GET("/admin/user", requireAdmin, handleGetUserAdmin) - api.POST("/admin/user/message", requireAdmin, handleMessageUser) - api.POST("/admin/user/profile", requireAdmin, handleAdminUpdateProfile) - api.POST("/admin/user/note", requireAdmin, handleSetAdminNote) - api.GET("/admin/projects", requireAdmin, handleAdminSearchProjects) - api.GET("/admin/stats", requireAdmin, handleAdminStats) - api.GET("/admin/storage", requireAdmin, handleAdminStorageStats) - api.POST("/admin/storage/sync", requireAdmin, handleAdminSyncR2StorageStats) - api.GET("/admin/users", requireAdmin, handleListUsers) - api.GET("/admin/agreement", requireAdmin, handleAdminGetAgreement) - api.POST("/admin/agreement", requireAdmin, handleAdminUpdateAgreement) - api.POST("/admin/reconcile-deletions", requireAdmin, handleReconcileDeletions) - api.GET("/admin/extensions", requireAdmin, handleAdminGetExtensions) - api.GET("/admin/errors", requireAdmin, handleAdminGetErrors) - api.POST("/admin/errors/resolve", requireAdmin, handleAdminResolveSiteError) - api.POST("/admin/errors/delete", requireAdmin, handleAdminDeleteSiteError) - api.POST("/admin/errors/delete-all", requireAdmin, handleAdminDeleteAllSiteErrors) - api.GET("/admin/extensions/:hash/source", requireAdmin, handleAdminGetExtensionSource) - api.POST("/admin/extensions/policy", requireAdmin, handleAdminSetExtensionPolicy) - api.POST("/admin/extensions/url-policy", requireAdmin, handleAdminSetExtensionUrlPolicy) - api.GET("/admin/classroom/plans", requireAdmin, handleAdminGetClassroomPlan) - api.POST("/admin/classroom/plans", requireAdmin, handleAdminSetClassroomPlan) - api.GET("/admin/classroom/schools", requireAdmin, handleAdminListSchools) - api.POST("/admin/classroom/schools", requireAdmin, handleAdminCreateSchool) - api.POST("/admin/classroom/purge", requireAdmin, handleAdminPurgeClassroom) + registerAuthRoutes(api) + registerRoturRoutes(api) + registerCommunityRoutes(api) + registerProjectRoutes(api) + registerGameRoutes(api) + registerStorageRoutes(api) + registerClassroomRoutes(api) + registerGitRoutes(api) + registerHistoryRoutes(api) + registerAdminRoutes(api) ) *serve.Router uploadsV1 = app.group("/v1") diff --git a/opal.json b/opal.json new file mode 100644 index 0000000..3da095f --- /dev/null +++ b/opal.json @@ -0,0 +1,14 @@ +{ + "name": "mistwarp-api", + "version": "1.0.0", + "main": "main.osl", + "scripts": { + "dev": ["osl", "run", "main.osl"], + "test": ["osl", "test"], + "check": ["osl", "lsp", "check", "main.osl"], + "build": ["osl", "compile", "main.osl", "-o", "main"], + "routes": ["python3", "scripts/route_dump.py", "."], + "smoke": ["python3", "scripts/e2e_smoke.py"], + "conventions": ["python3", "scripts/check_conventions.py"] + } +} diff --git a/scripts/__pycache__/check_conventions.cpython-314.pyc b/scripts/__pycache__/check_conventions.cpython-314.pyc new file mode 100644 index 0000000..2aa9485 Binary files /dev/null and b/scripts/__pycache__/check_conventions.cpython-314.pyc differ diff --git a/scripts/check_conventions.py b/scripts/check_conventions.py new file mode 100644 index 0000000..ded89f6 --- /dev/null +++ b/scripts/check_conventions.py @@ -0,0 +1,122 @@ +import argparse +import io +import re +import subprocess +import sys +import tokenize + +EM_DASH = "\u2014" +TEXT_FILE = re.compile(r"\.(osl|go|py|md|json|yml|yaml|sh|txt)$") +SKIPPED = [re.compile(r"(^|/)go\.sum$"), re.compile(r"^data/")] +DIRECTIVE = re.compile(r"^(//|#)\s*(nolint|noqa|go:|lint:|type:\s*ignore)") + + +def git(*args): + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout + + +def parse_diff(text): + files, removed = {}, set() + current, line = None, 0 + for row in text.split("\n"): + if row.startswith("+++ "): + path = row[4:] + current = None if path == "/dev/null" else re.sub(r"^b/", "", path) + if current is not None: + files.setdefault(current, {}) + elif row.startswith("@@"): + match = re.search(r"\+(\d+)", row) + line = int(match.group(1)) if match else 0 + elif row.startswith("+") and current is not None: + files[current][line] = row[1:] + line += 1 + elif row.startswith("-") and not row.startswith("---"): + removed.add(row[1:].strip()) + return files, removed + + +def c_style_comments(text): + found, line, index, quote = [], 1, 0, None + while index < len(text): + char = text[index] + if quote: + if char == "\\" and quote != "`": + index += 1 + elif char == quote: + quote = None + elif char == "\n": + line += 1 + elif char in "\"'`": + quote = char + elif text.startswith("//", index): + end = text.find("\n", index) + end = len(text) if end == -1 else end + found.append((line, text[index:end])) + index = end + continue + elif text.startswith("/*", index): + end = text.find("*/", index + 2) + end = len(text) if end == -1 else end + 2 + for offset, part in enumerate(text[index:end].split("\n")): + found.append((line + offset, part)) + line += text[index:end].count("\n") + index = end + continue + elif char == "\n": + line += 1 + index += 1 + return found + + +def python_comments(text): + found = [] + try: + for token in tokenize.generate_tokens(io.StringIO(text).readline): + if token.type == tokenize.COMMENT and not (token.start[0] == 1 and token.string.startswith("#!")): + found.append((token.start[0], token.string)) + except (tokenize.TokenError, SyntaxError): + return found + return found + + +SCANNERS = {".osl": c_style_comments, ".go": c_style_comments, ".py": python_comments} + + +def main(): + parser = argparse.ArgumentParser(description="Reject code comments and em dashes in added lines") + parser.add_argument("--base", default="origin/main") + parser.add_argument("--staged", action="store_true") + options = parser.parse_args() + diff_args = ["diff", "--cached"] if options.staged else ["diff", git("merge-base", options.base, "HEAD").strip()] + files, removed = parse_diff(git(*diff_args, "--unified=0", "--no-color", "--diff-filter=ACMRD")) + removed_text = "\n".join(removed) + problems = [] + for path, added in files.items(): + if not TEXT_FILE.search(path) or any(pattern.search(path) for pattern in SKIPPED): + continue + for line, content in added.items(): + if EM_DASH in content and content.strip() not in removed: + problems.append(f'{path}:{line} em dash: use a comma, parentheses or "to"') + scan = next((scanner for suffix, scanner in SCANNERS.items() if path.endswith(suffix)), None) + if scan is None: + continue + text = git("show", f":{path}") if options.staged else open(path, encoding="utf-8").read() + flagged = set() + for line, comment in scan(text): + portion = comment.strip() + if line in flagged or line not in added or DIRECTIVE.match(portion): + continue + if not re.search(r"[A-Za-z0-9]", portion) or portion in removed_text: + continue + flagged.add(line) + problems.append(f"{path}:{line} code comment: name things so the code explains itself") + if problems: + print("\n".join(problems), file=sys.stderr) + print(f"\n{len(problems)} convention problem{'' if len(problems) == 1 else 's'} in the changed lines.", file=sys.stderr) + return 1 + print("conventions ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/e2e_smoke.py b/scripts/e2e_smoke.py index 4964e8a..c376c7a 100644 --- a/scripts/e2e_smoke.py +++ b/scripts/e2e_smoke.py @@ -57,6 +57,10 @@ def __init__(self, binary, keep): os.makedirs(os.path.join(data, "sessions")) with open(os.path.join(self.dir, ".env"), "w") as env: env.write("PORT=%d\nAPP_URL=http://127.0.0.1:%d\nROTUR_BASE=http://127.0.0.1:9\n" % (self.port, self.port)) + with open(os.path.join(data, "agreement.json"), "w") as handle: + json.dump({"version": 1, "text": "", "updatedAt": 1700000000000}, handle) + with open(os.path.join(data, "agreement.md"), "w") as handle: + handle.write("Smoke test agreement") with open(os.path.join(data, "userids.json"), "w") as handle: json.dump({"users": {uid: name for name, uid in USERS.items()}}, handle) for name, uid in USERS.items(): @@ -187,7 +191,10 @@ def step(name, method, path, who=None, body=None, upload=None, headers=()): step("delete remix", "DELETE", "/v1/projects/", "alice") step("trash", "GET", "/v1/me/trash", "alice") step("purge remix", "DELETE", "/v1/trash/", "alice") + step("delete shared project refused", "DELETE", "/v1/projects/", "sam") + step("unshare", "POST", "/v1/projects//unpublish", "sam", {}) step("delete project", "DELETE", "/v1/projects/", "sam") + step("trash sam", "GET", "/v1/me/trash", "sam") step("missing project", "GET", "/v1/projects/nope") step("wrong method", "PATCH", "/v1/projects/", "sam", {}, headers=("Allow",)) step("legacy prefix", "GET", "/api/agreement") @@ -208,6 +215,7 @@ def main(): args = parser.parse_args() sb3 = open(args.sb3, "rb").read() if args.sb3 else default_sb3() outputs = [] + broken = False for binary in args.binaries: steps = run(binary, sb3, args.keep) text = json.dumps(steps, indent=2, sort_keys=True) + "\n" @@ -220,12 +228,15 @@ def main(): print("%s: %d steps, %d server errors" % (binary, len(steps), len(failures))) for record in steps: print(" %3d %s" % (record["status"], record["step"])) + broken = broken or bool(failures) if len(outputs) == 2: diff = list(difflib.unified_diff(outputs[0].splitlines(), outputs[1].splitlines(), args.binaries[0], args.binaries[1], lineterm="")) if diff: print("\n".join(diff)) sys.exit(1) print("responses match") + if broken: + sys.exit(1) if __name__ == "__main__": diff --git a/scripts/route_dump.py b/scripts/route_dump.py new file mode 100644 index 0000000..17c486d --- /dev/null +++ b/scripts/route_dump.py @@ -0,0 +1,127 @@ +import glob +import os +import re +import sys + +METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ANY"} +DEF = re.compile(r"^def (\w+)\((.*?)\)\s*\w*\s*\($") +CALL = re.compile(r"^\s*(?:void\s+)?(\w+)\((.*)\)\s*$") +GROUP = re.compile(r"^\s*\*serve\.Router (\w+) = (\w+)\.group\(\"([^\"]*)\"\)\s*$") +ROUTER = re.compile(r"^\s*\*serve\.Router (\w+) = serve\.new\(\)\s*$") +METHOD = re.compile(r"^\s*(\w+)\.(\w+)\((.*)\)\s*$") + + +def split_args(text): + args, depth, current, quote = [], 0, "", False + for i, ch in enumerate(text): + if ch == "\"" and (i == 0 or text[i - 1] != "\\"): + quote = not quote + if not quote: + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch == "," and depth == 0: + args.append(current.strip()) + current = "" + continue + current += ch + if current.strip(): + args.append(current.strip()) + return args + + +def load_functions(root): + functions = {} + for path in glob.glob(os.path.join(root, "**", "*.osl"), recursive=True): + if path.endswith(".test.osl") or "/.claude/" in path or "/.opal/" in path: + continue + lines = open(path).read().split("\n") + i = 0 + while i < len(lines): + match = DEF.match(lines[i]) + if match: + params = [p.strip().split()[-1] for p in split_args(match.group(2)) if p.strip()] + body = [] + i += 1 + while i < len(lines) and lines[i] != ")": + body.append(lines[i]) + i += 1 + functions[match.group(1)] = (params, body) + i += 1 + return functions + + +class Dump: + def __init__(self, functions): + self.functions = functions + self.routes = [] + self.order = {} + + def run(self, lines, routers, depth=0): + if depth > 20: + return + for line in lines: + if line.strip().startswith("//"): + continue + match = ROUTER.match(line) + if match: + routers[match.group(1)] = {"prefix": "", "mw": []} + continue + match = GROUP.match(line) + if match and match.group(2) in routers: + parent = routers[match.group(2)] + routers[match.group(1)] = {"prefix": parent["prefix"] + match.group(3), "mw": list(parent["mw"])} + continue + match = METHOD.match(line) + if match and match.group(1) in routers: + router = routers[match.group(1)] + name = match.group(2) + args = split_args(match.group(3)) + if name == "use": + router["mw"].extend(args) + elif name.upper() in METHODS or name in ("static", "ws", "staticFile"): + path = router["prefix"] + args[0].strip("\"") + kind = name.upper() if name.upper() in METHODS else name + self.routes.append("%s %s | %s" % (kind, path, " , ".join(router["mw"] + args[1:]))) + self.order.setdefault(path, []).append(kind) + else: + self.routes.append("%s.%s(%s) on %s" % ("router", name, ", ".join(args), router["prefix"] or "/")) + continue + match = CALL.match(line) + if match and match.group(1) in self.functions: + params, body = self.functions[match.group(1)] + args = split_args(match.group(2)) + scope = dict(routers) + for param, arg in zip(params, args): + if arg in routers: + scope[param] = routers[arg] + if any(arg in routers for arg in args) or any(re.search(r"\b(%s)\.\w+\(" % "|".join(map(re.escape, scope)), l) for l in body): + self.run(body, scope, depth + 1) + + +def main(): + root = sys.argv[1] if len(sys.argv) > 1 else "." + functions = load_functions(root) + lines = [l for l in open(os.path.join(root, "main.osl")).read().split("\n")] + top = [] + skip = False + for line in lines: + if DEF.match(line): + skip = True + continue + if skip: + if line == ")": + skip = False + continue + top.append(line) + dump = Dump(functions) + dump.run(top, {}) + for route in sorted(dump.routes): + print(route) + for path in sorted(dump.order): + print("ORDER %s %s" % (path, ",".join(dump.order[path]))) + + +if __name__ == "__main__": + main() diff --git a/admin-user-policy.osl b/src/admin/admin-user-policy.osl similarity index 99% rename from admin-user-policy.osl rename to src/admin/admin-user-policy.osl index cd83208..3e9cdeb 100644 --- a/admin-user-policy.osl +++ b/src/admin/admin-user-policy.osl @@ -1,3 +1,5 @@ +export {} + def adminReportRole(object report, string key) string ( string subject = (report.subject ?? report.targetUser ?? "").toStr() if subject == "" and report.type.toStr() == "user" ( diff --git a/admin-user-policy.test.osl b/src/admin/admin-user-policy.test.osl similarity index 100% rename from admin-user-policy.test.osl rename to src/admin/admin-user-policy.test.osl diff --git a/admin-users.osl b/src/admin/admin-users.osl similarity index 99% rename from admin-users.osl rename to src/admin/admin-users.osl index e94dcb1..68db3da 100644 --- a/admin-users.osl +++ b/src/admin/admin-users.osl @@ -1,3 +1,9 @@ +export { + indexComment, + migrateCommentIndex, + unindexComment +} + def adminNotesFile() string ( return "data/admin-notes.json" ) diff --git a/admin.osl b/src/admin/admin.osl similarity index 98% rename from admin.osl rename to src/admin/admin.osl index 20f0aba..2856ba9 100644 --- a/admin.osl +++ b/src/admin/admin.osl @@ -1,3 +1,23 @@ +export { + banReasonFor, + banUser, + cachedAdmins, + isBannedUser, + loadAdmins, + loadBans, + projectHiddenFrom, + projectOwnerBanned, + projectsVisibleTo, + projectsVisibleToEveryone, + saveAdmins, + saveBans, + syncRoturBans, + unbanUser +} + +auto bansCache = cache.new({capacity: 2, ttl: 300000}) +auto adminsCache = cache.new({capacity: 2, ttl: 300000}) + def loadAdmins() array ( auto res = readFile(adminsFile) if !res.ok ( diff --git a/banned-owners.osl b/src/admin/banned-owners.osl similarity index 99% rename from banned-owners.osl rename to src/admin/banned-owners.osl index 254cd21..1abf7c1 100644 --- a/banned-owners.osl +++ b/src/admin/banned-owners.osl @@ -1,3 +1,7 @@ +export { + entryOwnerBanned +} + // A banned user's projects are hidden everywhere except from admins. Bans are // kept by username; the set also holds each banned user's ID, so a project // still matches if its stored owner name is out of date after a rename. diff --git a/banned-owners.test.osl b/src/admin/banned-owners.test.osl similarity index 100% rename from banned-owners.test.osl rename to src/admin/banned-owners.test.osl diff --git a/errors.osl b/src/admin/errors.osl similarity index 99% rename from errors.osl rename to src/admin/errors.osl index 2026f12..74ed10f 100644 --- a/errors.osl +++ b/src/admin/errors.osl @@ -1,3 +1,7 @@ +export { + sweepSiteErrors +} + number siteErrorRetentionMs = 7 * 86400000 array siteErrorPathWords = [ diff --git a/errors.test.osl b/src/admin/errors.test.osl similarity index 99% rename from errors.test.osl rename to src/admin/errors.test.osl index b321139..84e6b51 100644 --- a/errors.test.osl +++ b/src/admin/errors.test.osl @@ -4,7 +4,7 @@ import "std:sync" import "std:serve" import "std:fs" import "std:regex" -import "./request-body.osl" +import "../http/request-body.osl" string path = "/tmp/mistwarp-errors-" ++ timestamp.toStr() ++ ".db" auto testDb = db.open(path) diff --git a/funnel-events-policy.osl b/src/admin/funnel-events-policy.osl similarity index 99% rename from funnel-events-policy.osl rename to src/admin/funnel-events-policy.osl index 71056f0..36eeba8 100644 --- a/funnel-events-policy.osl +++ b/src/admin/funnel-events-policy.osl @@ -1,3 +1,5 @@ +export {} + object funnelEventVariants = { editor_open: [], home_view: [], diff --git a/funnel-events-policy.test.osl b/src/admin/funnel-events-policy.test.osl similarity index 100% rename from funnel-events-policy.test.osl rename to src/admin/funnel-events-policy.test.osl diff --git a/funnel-events.osl b/src/admin/funnel-events.osl similarity index 99% rename from funnel-events.osl rename to src/admin/funnel-events.osl index ef1f081..001631a 100644 --- a/funnel-events.osl +++ b/src/admin/funnel-events.osl @@ -1,3 +1,5 @@ +export {} + def recordFunnelEvent(string key, number now) ( string id = "funnel:" ++ funnelDayKey(now) sync.lock("funnel-events") diff --git a/project-moderation.osl b/src/admin/project-moderation.osl similarity index 97% rename from project-moderation.osl rename to src/admin/project-moderation.osl index d6f90ba..8a4897f 100644 --- a/project-moderation.osl +++ b/src/admin/project-moderation.osl @@ -1,3 +1,19 @@ +export { + commentsVisibleTo, + moderationHoldResponse, + projectHiddenByModerator, + projectShadowBanEntry, + refuseIfHiddenByModerator, + removeShadowBannedUser, + renameShadowBannedUser, + reviewsVisibleTo, + shadowBannedSet, + userShadowBanned, + viewerSeesShadowBanned +} + +auto shadowBansCache = cache.new({capacity: 2, ttl: 300000}) + def loadShadowBans() object ( auto res = readFile(shadowBansFile) if !res.ok ( diff --git a/src/admin/routes.osl b/src/admin/routes.osl new file mode 100644 index 0000000..b2e58d6 --- /dev/null +++ b/src/admin/routes.osl @@ -0,0 +1,39 @@ +export { + registerAdminRoutes +} + +def registerAdminRoutes(*serve.Router api) ( + api.POST("/errors", serve.limitRequests(20, {window: 60000}), handleRecordSiteError) + api.GET("/stats", handlePublicStats) + api.POST("/events", serve.limitRequests(120, {window: 60000}), handleRecordFunnelEvent) + + api.POST("/admin/reports/action", requireAdmin, handleReportAction) + + api.GET("/admin/admins", requireAdmin, handleListAdmins) + api.POST("/admin/admins", requireAdmin, handleAddAdmin) + api.POST("/admin/admins/remove", requireAdmin, handleRemoveAdmin) + api.GET("/admin/bans", requireAdmin, handleListBans) + api.POST("/admin/ban", requireAdmin, handleBanUser) + api.POST("/admin/unban", requireAdmin, handleUnbanUser) + api.GET("/admin/shadowbans", requireAdmin, handleListShadowBans) + api.POST("/admin/shadowban", requireAdmin, handleShadowBanUser) + api.POST("/admin/unshadowban", requireAdmin, handleLiftShadowBan) + api.POST("/admin/projects/:id/hide", requireAdmin, handleHideProject) + api.POST("/admin/projects/:id/restore", requireAdmin, handleRestoreHiddenProject) + api.POST("/admin/projects/:id/shadowban", requireAdmin, handleShadowBanProject) + api.POST("/admin/projects/:id/unshadowban", requireAdmin, handleLiftProjectShadowBan) + + api.GET("/admin/user", requireAdmin, handleGetUserAdmin) + + api.POST("/admin/user/note", requireAdmin, handleSetAdminNote) + api.GET("/admin/projects", requireAdmin, handleAdminSearchProjects) + api.GET("/admin/stats", requireAdmin, handleAdminStats) + api.GET("/admin/storage", requireAdmin, handleAdminStorageStats) + api.POST("/admin/storage/sync", requireAdmin, handleAdminSyncR2StorageStats) + api.GET("/admin/users", requireAdmin, handleListUsers) + + api.GET("/admin/errors", requireAdmin, handleAdminGetErrors) + api.POST("/admin/errors/resolve", requireAdmin, handleAdminResolveSiteError) + api.POST("/admin/errors/delete", requireAdmin, handleAdminDeleteSiteError) + api.POST("/admin/errors/delete-all", requireAdmin, handleAdminDeleteAllSiteErrors) +) diff --git a/src/admin/site-error-origins.osl b/src/admin/site-error-origins.osl new file mode 100644 index 0000000..7b2839a --- /dev/null +++ b/src/admin/site-error-origins.osl @@ -0,0 +1,9 @@ +export {} + +array errorReportOrigins = [] +for entry in (editorOrigin ++ "," ++ configValue("ERROR_REPORT_ORIGINS", "")).split(",") ( + string normalized = siteErrorOrigin(entry, "") + if normalized != "" ( + errorReportOrigins.append(normalized) + ) +) diff --git a/account-controls.osl b/src/auth/account-controls.osl similarity index 99% rename from account-controls.osl rename to src/auth/account-controls.osl index 4ed1681..b260033 100644 --- a/account-controls.osl +++ b/src/auth/account-controls.osl @@ -1,3 +1,10 @@ +export { + eraseUser, + loadSafety, + userHasBlocked, + usersAreBlocked +} + def safetyPath(string username) string ( return safetyDir ++ normalizeUsername(username) ++ ".json" ) diff --git a/agreement.osl b/src/auth/agreement.osl similarity index 99% rename from agreement.osl rename to src/auth/agreement.osl index efdcaa5..ddf64be 100644 --- a/agreement.osl +++ b/src/auth/agreement.osl @@ -1,3 +1,7 @@ +export { + requireCurrentAgreement +} + def agreementMetaPath string ( return "data/agreement.json" ) diff --git a/auth.osl b/src/auth/auth.osl similarity index 98% rename from auth.osl rename to src/auth/auth.osl index 86762b2..03d0d14 100644 --- a/auth.osl +++ b/src/auth/auth.osl @@ -1,3 +1,14 @@ +export { + cleanupExpiredSessions, + createSession, + getSession, + isAdminUser, + requestToken, + setAuthCookie +} + +auto sessionCache = cache.new({capacity: 10000, ttl: 3600000}) + def setAuthCookie(*serve.Context c, string sessionId) ( c.setCookie("auth_token", sessionId, {maxAge: 604800000, path: "/", domain: "", secure: appURL.startsWith("https://"), httpOnly: true}) ) diff --git a/editor-session-policy.osl b/src/auth/editor-session-policy.osl similarity index 96% rename from editor-session-policy.osl rename to src/auth/editor-session-policy.osl index 1abba72..227baed 100644 --- a/editor-session-policy.osl +++ b/src/auth/editor-session-policy.osl @@ -1,3 +1,10 @@ +export { + editorCanDeleteProject, + editorProjectUpdateAllowed, + escapedRequestPath, + scopedRequestAllowed +} + number editorSessionTtlMs = 7200000 number editorSessionMintsPerHour = 60 diff --git a/editor-session-policy.test.osl b/src/auth/editor-session-policy.test.osl similarity index 99% rename from editor-session-policy.test.osl rename to src/auth/editor-session-policy.test.osl index c4d27a7..135a5ed 100644 --- a/editor-session-policy.test.osl +++ b/src/auth/editor-session-policy.test.osl @@ -1,6 +1,6 @@ import "std:testing" import "std:regex" -import "./classroom-policy.osl" +import "../classroom/classroom-policy.osl" import "./editor-session-policy.osl" def projectHasStoredContent(object project) boolean ( diff --git a/privacy-policy.osl b/src/auth/privacy-policy.osl similarity index 93% rename from privacy-policy.osl rename to src/auth/privacy-policy.osl index b3a67ea..5de4246 100644 --- a/privacy-policy.osl +++ b/src/auth/privacy-policy.osl @@ -1,3 +1,14 @@ +export { + activityItemVisible, + activityPrivacy, + activityProjectIds, + effectiveProfileCommentsOff, + effectiveShowRecentActivity, + profileHasChoice, + profileIsMinor, + profileSearchable +} + def roturReportsMinor(object data) boolean ( return data.contains("minor") and data.minor == true ) diff --git a/privacy-policy.test.osl b/src/auth/privacy-policy.test.osl similarity index 99% rename from privacy-policy.test.osl rename to src/auth/privacy-policy.test.osl index 8ebe582..db2dbec 100644 --- a/privacy-policy.test.osl +++ b/src/auth/privacy-policy.test.osl @@ -1,6 +1,6 @@ import "std:testing" import "std:regex" -import "./classroom-policy.osl" +import "../classroom/classroom-policy.osl" import "./privacy-policy.osl" testing.assert(roturReportsMinor({valid: true, minor: true}), "rotur can report a minor") diff --git a/src/auth/routes.osl b/src/auth/routes.osl new file mode 100644 index 0000000..99a2d80 --- /dev/null +++ b/src/auth/routes.osl @@ -0,0 +1,32 @@ +export { + registerAuthRoutes +} + +def registerAuthRoutes(*serve.Router api) ( + api.GET("/health", handleHealth) + api.GET("/health/dependencies", handleDependencyHealth) + + api.POST("/auth", handleRoturAuth) + api.POST("/auth/editor-session", serve.limitRequests(600, {window: 3600000}), requireAuth, handleCreateEditorSession) + api.GET("/me", handleGetMe) + api.POST("/logout", handleLogout) + + api.GET("/me/settings", requireAuth, handleGetMySettings) + api.PUT("/me/settings", requireAuth, handlePutMySettings) + + api.GET("/me/export", requireAuth, handleExportMyData) + api.DELETE("/me/data", requireAuth, handleDeleteMyData) + + api.GET("/me/safety", requireAuth, handleGetMySafety) + api.POST("/me/blocks/:name", requireAuth, handleBlockUser) + api.DELETE("/me/blocks/:name", requireAuth, handleUnblockUser) + api.POST("/me/mutes/:name", requireAuth, handleMuteUser) + api.DELETE("/me/mutes/:name", requireAuth, handleUnmuteUser) + + api.GET("/agreement", handleGetAgreement) + api.POST("/agreement/accept", requireAuth, handleAcceptAgreement) + + api.GET("/admin/agreement", requireAdmin, handleAdminGetAgreement) + api.POST("/admin/agreement", requireAdmin, handleAdminUpdateAgreement) + api.POST("/admin/reconcile-deletions", requireAdmin, handleReconcileDeletions) +) diff --git a/settings.osl b/src/auth/settings.osl similarity index 99% rename from settings.osl rename to src/auth/settings.osl index 82ce50f..25abb25 100644 --- a/settings.osl +++ b/src/auth/settings.osl @@ -1,3 +1,5 @@ +export {} + def settingsPath(string username) string ( return userSettingsDir ++ normalizeUsername(username) ++ ".json" ) diff --git a/user-identity.osl b/src/auth/user-identity.osl similarity index 98% rename from user-identity.osl rename to src/auth/user-identity.osl index b2021a7..28121b0 100644 --- a/user-identity.osl +++ b/src/auth/user-identity.osl @@ -1,3 +1,22 @@ +export { + discoverUserIdentity, + identityMatches, + loadUserIds, + purgeUser, + purgeUserSessions, + reconcileLoop, + replaceUserInArray, + rewriteNotificationIdentityFields, + roturServiceOptions, + saveUserIds, + userIdForUsername, + userIdsCache, + usernameForId +} + +auto userIdsCache = cache.new({capacity: 10000, ttl: 300000}) +auto identityRefreshCache = cache.new({capacity: 10000, ttl: 300000}) + def loadUserIds() object ( auto res = readFile(userIdsFile) if !res.ok ( diff --git a/classroom-billing-policy.osl b/src/classroom/classroom-billing-policy.osl similarity index 99% rename from classroom-billing-policy.osl rename to src/classroom/classroom-billing-policy.osl index 2891ee2..a4f989e 100644 --- a/classroom-billing-policy.osl +++ b/src/classroom/classroom-billing-policy.osl @@ -1,3 +1,5 @@ +export {} + number classroomSeatPackSize = 10 number classroomBillingGraceMs = 259200000 number stripeSignatureToleranceSeconds = 300 diff --git a/classroom-billing-policy.test.osl b/src/classroom/classroom-billing-policy.test.osl similarity index 100% rename from classroom-billing-policy.test.osl rename to src/classroom/classroom-billing-policy.test.osl diff --git a/classroom-billing.osl b/src/classroom/classroom-billing.osl similarity index 99% rename from classroom-billing.osl rename to src/classroom/classroom-billing.osl index 0a0f214..c3e0350 100644 --- a/classroom-billing.osl +++ b/src/classroom/classroom-billing.osl @@ -1,3 +1,5 @@ +export {} + def stripeBillingConfigured() boolean ( return stripeSecretKey != "" and stripeWebhookSecret != "" and classroomBillingIntervals(stripeClassroomPrice, stripeClassroomPriceYearly).len > 0 ) diff --git a/classroom-groups.osl b/src/classroom/classroom-groups.osl similarity index 99% rename from classroom-groups.osl rename to src/classroom/classroom-groups.osl index b797d95..e21a227 100644 --- a/classroom-groups.osl +++ b/src/classroom/classroom-groups.osl @@ -1,3 +1,5 @@ +export {} + number classroomGroupMinSize = 2 number classroomGroupMaxSize = 6 diff --git a/classroom-legal-policy.osl b/src/classroom/classroom-legal-policy.osl similarity index 99% rename from classroom-legal-policy.osl rename to src/classroom/classroom-legal-policy.osl index fbb0df7..5924889 100644 --- a/classroom-legal-policy.osl +++ b/src/classroom/classroom-legal-policy.osl @@ -1,3 +1,5 @@ +export {} + string classroomTermsVersion = "2026-10-01" number classroomRetentionWarnMs = 335 * 86400000 number classroomRetentionDeleteMs = 365 * 86400000 diff --git a/classroom-legal-policy.test.osl b/src/classroom/classroom-legal-policy.test.osl similarity index 100% rename from classroom-legal-policy.test.osl rename to src/classroom/classroom-legal-policy.test.osl diff --git a/classroom-legal.osl b/src/classroom/classroom-legal.osl similarity index 99% rename from classroom-legal.osl rename to src/classroom/classroom-legal.osl index a8ae03a..efd9145 100644 --- a/classroom-legal.osl +++ b/src/classroom/classroom-legal.osl @@ -1,3 +1,7 @@ +export { + eraseClassroomTeacher +} + def classroomTermsRecord(string userId) object ( return (classroomTermsDocs.findById(userId) ?? {}) ) diff --git a/classroom-policy.osl b/src/classroom/classroom-policy.osl similarity index 98% rename from classroom-policy.osl rename to src/classroom/classroom-policy.osl index 9c88053..b132526 100644 --- a/classroom-policy.osl +++ b/src/classroom/classroom-policy.osl @@ -1,3 +1,11 @@ +export { + apiRoutePath, + isStudentUserId, + isStudentUsername, + studentProjectUpdateAllowed, + studentRouteAllowed +} + string studentIdPrefix = "mwc_" string studentUsernameSeparator = "~" number studentLoginMaxFailures = 5 diff --git a/classroom-policy.test.osl b/src/classroom/classroom-policy.test.osl similarity index 100% rename from classroom-policy.test.osl rename to src/classroom/classroom-policy.test.osl diff --git a/classroom-present.osl b/src/classroom/classroom-present.osl similarity index 99% rename from classroom-present.osl rename to src/classroom/classroom-present.osl index a7d46d2..af9b5f8 100644 --- a/classroom-present.osl +++ b/src/classroom/classroom-present.osl @@ -1,3 +1,7 @@ +export { + presentationViewerRole +} + number classroomPresentationLifetimeMs = 14400000 def activePresentationForProject(string projectId) object ( diff --git a/classroom-release.osl b/src/classroom/classroom-release.osl similarity index 99% rename from classroom-release.osl rename to src/classroom/classroom-release.osl index 4904c41..987e3a7 100644 --- a/classroom-release.osl +++ b/src/classroom/classroom-release.osl @@ -1,3 +1,5 @@ +export {} + number classroomClaimLifetimeMs = 1209600000 def loadClassroomClaim(string code) object ( diff --git a/classroom-schools.osl b/src/classroom/classroom-schools.osl similarity index 99% rename from classroom-schools.osl rename to src/classroom/classroom-schools.osl index bb64230..0557584 100644 --- a/classroom-schools.osl +++ b/src/classroom/classroom-schools.osl @@ -1,3 +1,5 @@ +export {} + def schoolForTeacher(string userId) object ( if userId == "" or isStudentUserId(userId) ( return {} diff --git a/classroom-switch-policy.osl b/src/classroom/classroom-switch-policy.osl similarity index 99% rename from classroom-switch-policy.osl rename to src/classroom/classroom-switch-policy.osl index 7b56331..c316ba3 100644 --- a/classroom-switch-policy.osl +++ b/src/classroom/classroom-switch-policy.osl @@ -1,3 +1,5 @@ +export {} + def classroomFlagOn(string value) boolean ( string flag = value.toLower().strip() return flag == "1" or flag == "true" or flag == "yes" or flag == "on" diff --git a/classroom-switch-policy.test.osl b/src/classroom/classroom-switch-policy.test.osl similarity index 100% rename from classroom-switch-policy.test.osl rename to src/classroom/classroom-switch-policy.test.osl diff --git a/classroom-switch.osl b/src/classroom/classroom-switch.osl similarity index 97% rename from classroom-switch.osl rename to src/classroom/classroom-switch.osl index 1fc5e75..47c969a 100644 --- a/classroom-switch.osl +++ b/src/classroom/classroom-switch.osl @@ -1,3 +1,9 @@ +export { + classroomSessionAllowed, + classroomSwitchGuard, + startClassroomTimers +} + boolean classroomEnabled = classroomFlagOn(configValue("CLASSROOM_ENABLED", "")) def classroomTestClass(object classDoc) boolean ( diff --git a/classroom-switch.test.osl b/src/classroom/classroom-switch.test.osl similarity index 99% rename from classroom-switch.test.osl rename to src/classroom/classroom-switch.test.osl index b2a5ddf..2bbae3e 100644 --- a/classroom-switch.test.osl +++ b/src/classroom/classroom-switch.test.osl @@ -8,7 +8,7 @@ import "std:serve" import "std:sync" import "std:thread" import "std:url" -import "./request-body.osl" +import "../http/request-body.osl" import "./classroom-policy.osl" import "./classroom-switch-policy.osl" diff --git a/classrooms.osl b/src/classroom/classrooms.osl similarity index 99% rename from classrooms.osl rename to src/classroom/classrooms.osl index 60a399b..05a096d 100644 --- a/classrooms.osl +++ b/src/classroom/classrooms.osl @@ -1,3 +1,13 @@ +export { + classTeacherRoleForProject, + classesOwnedBy, + classroomAccessGuard, + classroomStudentBenefits, + loadStudent +} + +auto classroomPoolCache = cache.new({capacity: 10000, ttl: 60000}) + def randomFromAlphabet(string alphabet, number length) string ( string out = "" for _ length ( diff --git a/src/classroom/routes.osl b/src/classroom/routes.osl new file mode 100644 index 0000000..449f58a --- /dev/null +++ b/src/classroom/routes.osl @@ -0,0 +1,61 @@ +export { + registerClassroomRoutes +} + +def registerClassroomRoutes(*serve.Router api) ( + api.GET("/classroom/join/:code", serve.limitRequests(30, {window: 60000}), handleClassroomJoinInfo) + api.POST("/classroom/login", serve.limitRequests(20, {window: 60000}), handleClassroomLogin) + api.GET("/classroom/available", handleClassroomAvailable) + api.GET("/classroom", requireAuth, handleGetClassroom) + api.POST("/classroom/terms/accept", requireClassroomTeacher, serve.limitRequests(20, {window: 3600000}), handleAcceptClassroomTerms) + api.GET("/classroom/school", requireClassroomTeacher, handleGetSchool) + api.GET("/classroom/school/audit", requireClassroomTeacher, handleGetSchoolAudit) + api.GET("/classroom/school/export", requireClassroomTeacher, serve.limitRequests(10, {window: 3600000}), handleExportSchool) + api.POST("/classroom/school/teachers", requireClassroomTeacher, requireClassroomTerms, handleAddSchoolTeacher) + api.DELETE("/classroom/school/teachers/:teacher", requireClassroomTeacher, handleRemoveSchoolTeacher) + api.PUT("/classroom/school/teachers/:teacher/admin", requireClassroomTeacher, handleSetSchoolAdmin) + api.POST("/classroom/billing/checkout", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(20, {window: 3600000}), handleClassroomCheckout) + api.POST("/classroom/billing/portal", requireClassroomTeacher, serve.limitRequests(30, {window: 3600000}), handleClassroomBillingPortal) + api.POST("/classroom/billing/webhook", handleStripeWebhook) + api.POST("/classroom/trial", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(10, {window: 3600000}), handleStartClassroomTrial) + api.POST("/classroom/classes", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(20, {window: 3600000}), handleCreateClass) + api.GET("/classroom/classes/:id", requireClassroomTeacher, handleGetClass) + api.PUT("/classroom/classes/:id", requireClassroomTeacher, handleUpdateClass) + api.DELETE("/classroom/classes/:id", requireClassroomTeacher, handleDeleteClass) + api.POST("/classroom/classes/:id/code", requireClassroomTeacher, handleResetClassCode) + api.POST("/classroom/classes/:id/teachers", requireClassroomTeacher, requireClassroomTerms, handleAddCoTeacher) + api.DELETE("/classroom/classes/:id/teachers/:teacher", requireClassroomTeacher, handleRemoveCoTeacher) + api.POST("/classroom/classes/:id/transfer", requireClassroomTeacher, handleTransferClass) + api.GET("/classroom/classes/:id/audit", requireClassroomTeacher, handleGetClassAudit) + api.GET("/classroom/classes/:id/export", requireClassroomTeacher, serve.limitRequests(30, {window: 3600000}), handleExportClass) + api.GET("/classroom/classes/:id/groups", requireClassroomTeacher, handleListClassGroups) + api.POST("/classroom/classes/:id/present", requireClassroomTeacher, requireClassroomTerms, handleStartPresentation) + api.DELETE("/classroom/classes/:id/present", requireClassroomTeacher, handleStopPresentation) + api.POST("/classroom/classes/:id/groups", requireClassroomTeacher, requireClassroomTerms, handleCreateClassGroup) + api.DELETE("/classroom/classes/:id/groups/:gid", requireClassroomTeacher, handleDeleteClassGroup) + api.POST("/classroom/classes/:id/students", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(30, {window: 3600000}), handleCreateStudents) + api.GET("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleGetClassStudent) + api.GET("/classroom/classes/:id/students/:sid/export", requireClassroomTeacher, serve.limitRequests(60, {window: 3600000}), handleExportClassStudent) + api.PUT("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleUpdateClassStudent) + api.DELETE("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleDeleteClassStudent) + api.POST("/classroom/classes/:id/students/:sid/reset", requireClassroomTeacher, handleResetStudentLogin) + api.POST("/classroom/classes/:id/students/:sid/move", requireClassroomTeacher, handleMoveClassStudent) + api.POST("/classroom/classes/:id/students/:sid/release", requireClassroomTeacher, requireClassroomTerms, handleReleaseStudent) + api.DELETE("/classroom/classes/:id/students/:sid/release", requireClassroomTeacher, handleCancelStudentRelease) + api.GET("/classroom/claim/:code", serve.limitRequests(30, {window: 60000}), handleGetClaim) + api.POST("/classroom/claim/:code", requireAuth, serve.limitRequests(10, {window: 60000}), handleRedeemClaim) + api.POST("/classroom/classes/:id/assignments", requireClassroomTeacher, requireClassroomTerms, handleCreateAssignment) + api.GET("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleGetAssignment) + api.PUT("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleUpdateAssignment) + api.DELETE("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleDeleteAssignment) + api.PUT("/classroom/classes/:id/assignments/:aid/submissions/:sid", requireClassroomTeacher, handleReviewSubmission) + api.POST("/classroom/assignments/:aid/start", requireAuth, handleStartAssignment) + api.POST("/classroom/assignments/:aid/turn-in", requireAuth, handleTurnInAssignment) + api.POST("/classroom/assignments/:aid/unsubmit", requireAuth, handleUnsubmitAssignment) + + api.GET("/admin/classroom/plans", requireAdmin, handleAdminGetClassroomPlan) + api.POST("/admin/classroom/plans", requireAdmin, handleAdminSetClassroomPlan) + api.GET("/admin/classroom/schools", requireAdmin, handleAdminListSchools) + api.POST("/admin/classroom/schools", requireAdmin, handleAdminCreateSchool) + api.POST("/admin/classroom/purge", requireAdmin, handleAdminPurgeClassroom) +) diff --git a/bounties.osl b/src/community/bounties.osl similarity index 99% rename from bounties.osl rename to src/community/bounties.osl index 046c826..a15f7b6 100644 --- a/bounties.osl +++ b/src/community/bounties.osl @@ -1,3 +1,5 @@ +export {} + def bountyCommentKey(string bountyId) string ( return "bounty-" ++ bountyId ) diff --git a/challenge-ranking.osl b/src/community/challenge-ranking.osl similarity index 95% rename from challenge-ranking.osl rename to src/community/challenge-ranking.osl index 670655a..c5e625c 100644 --- a/challenge-ranking.osl +++ b/src/community/challenge-ranking.osl @@ -1,3 +1,5 @@ +export {} + def rankChallengeEntries(array projects) array ( array ranked = projects.sortBy("rankScore", "descending") for i ranked.len ( diff --git a/challenge-ranking.test.osl b/src/community/challenge-ranking.test.osl similarity index 100% rename from challenge-ranking.test.osl rename to src/community/challenge-ranking.test.osl diff --git a/comments.osl b/src/community/comments.osl similarity index 99% rename from comments.osl rename to src/community/comments.osl index 945a721..e5bf62e 100644 --- a/comments.osl +++ b/src/community/comments.osl @@ -1,3 +1,11 @@ +export { + commentResponse, + createComment, + deleteComment, + editComment, + notifyMentions +} + def createComment(string key, string author, string authorId, string content, string parentId, string kind, number donationAmount, string donationPaymentId) object ( string trimmed = content.strip() if trimmed == "" ( diff --git a/community-tools.osl b/src/community/community-tools.osl similarity index 99% rename from community-tools.osl rename to src/community/community-tools.osl index c8080b8..481d662 100644 --- a/community-tools.osl +++ b/src/community/community-tools.osl @@ -1,3 +1,17 @@ +export { + creditPlaytimeSession, + migrateSpaceMemberships, + purgeUserCommunityRecords, + purgeUserFromSpaces, + recordProjectDiagnostic, + removeProjectFromSpaces, + removeProjectsFromSpaces, + stringArrayContainsUser, + syncSpaceMemberships, + warmShareCard, + xmlEscape +} + def stringArrayContainsUser(array users, string username) boolean ( string wanted = normalizeUsername(username) for i users.len ( diff --git a/development-links.osl b/src/community/development-links.osl similarity index 99% rename from development-links.osl rename to src/community/development-links.osl index 9c840de..f1ea07c 100644 --- a/development-links.osl +++ b/src/community/development-links.osl @@ -1,3 +1,5 @@ +export {} + // Pure helpers behind the development feed: normalising a GitHub search item // into a feed entry, parsing the pull request references an admin types, and // resolving which pull requests belong to a roadmap entry. Kept separate from diff --git a/development-links.test.osl b/src/community/development-links.test.osl similarity index 100% rename from development-links.test.osl rename to src/community/development-links.test.osl diff --git a/development.osl b/src/community/development.osl similarity index 98% rename from development.osl rename to src/community/development.osl index a8dcca9..0638d71 100644 --- a/development.osl +++ b/src/community/development.osl @@ -1,3 +1,10 @@ +export { + developmentFeedLoop, + nowSeconds +} + +auto developmentCache = cache.new({capacity: 4, ttl: 300000}) + // MistWarp's own source lives in public GitHub repositories, so the work in // flight there is invisible from inside the site. This pulls the org's open // and recently merged pull requests into the community API and links them to diff --git a/groups.osl b/src/community/groups.osl similarity index 97% rename from groups.osl rename to src/community/groups.osl index 8d39de3..5b0bfc8 100644 --- a/groups.osl +++ b/src/community/groups.osl @@ -1,3 +1,12 @@ +export { + canManageGroupContent, + canManageGroupTag, + groupTagOf, + normalizeGroupTag +} + +auto groupSnapshotCache = cache.new({capacity: 10000, ttl: 60000}) + def normalizeGroupTag(string value) string ( string tag = value.toLower().strip() if tag.len < 1 or tag.len > 10 or !regex.match("^[a-z0-9]+$", tag) ( diff --git a/like-milestone-policy.osl b/src/community/like-milestone-policy.osl similarity index 97% rename from like-milestone-policy.osl rename to src/community/like-milestone-policy.osl index ed889da..d3f4089 100644 --- a/like-milestone-policy.osl +++ b/src/community/like-milestone-policy.osl @@ -1,3 +1,5 @@ +export {} + // Only new crossings count. Existing likes are not backfilled on rollout. def crossedLikeMilestones(number before, number after, number notifiedThrough) array ( array res = [] diff --git a/like-milestones.osl b/src/community/like-milestones.osl similarity index 97% rename from like-milestones.osl rename to src/community/like-milestones.osl index 4a33316..ab0ea77 100644 --- a/like-milestones.osl +++ b/src/community/like-milestones.osl @@ -1,3 +1,7 @@ +export { + notifyLikeMilestones +} + // Claims survive inbox clearing and server restarts. Serialize claims for the // same item so simultaneous reactions cannot send the same milestone twice. def notifyLikeMilestones(string target, string kind, string contentId, number before, number after, string path, object extra) ( diff --git a/like-milestones.test.osl b/src/community/like-milestones.test.osl similarity index 98% rename from like-milestones.test.osl rename to src/community/like-milestones.test.osl index 68d4a79..0bebd96 100644 --- a/like-milestones.test.osl +++ b/src/community/like-milestones.test.osl @@ -16,7 +16,6 @@ string path = "/tmp/mistwarp-milestones-" ++ timestamp.toStr() ++ ".db" auto milestoneDb = db.open(path) *db.Collection likeMilestoneDocs = milestoneDb.collection("milestones") string roturBase = "http://127.0.0.1:18342" -auto remoteMilestoneChecks = cache.new({capacity: 100, ttl: 10000}) array sent = [] def isSafePathPart(string value) boolean ( diff --git a/news.osl b/src/community/news.osl similarity index 99% rename from news.osl rename to src/community/news.osl index 6214744..0f6697d 100644 --- a/news.osl +++ b/src/community/news.osl @@ -1,3 +1,11 @@ +export { + isValidReaction, + loadNews, + migratePolicyNewsPosts, + purgeUserFromNews, + renameUserInNews +} + def loadNews() array ( auto res = readFile(newsFile) if !res.ok ( diff --git a/notifications.osl b/src/community/notifications.osl similarity index 99% rename from notifications.osl rename to src/community/notifications.osl index 0a70d1b..c1ad591 100644 --- a/notifications.osl +++ b/src/community/notifications.osl @@ -1,3 +1,9 @@ +export { + addNotification, + loadNotifications, + saveNotifications +} + def loadNotifications(string username) array ( string key = normalizeUsername(username) if !isSafePathPart(key) ( diff --git a/remote-milestones.osl b/src/community/remote-milestones.osl similarity index 97% rename from remote-milestones.osl rename to src/community/remote-milestones.osl index e91c04f..fa3aea7 100644 --- a/remote-milestones.osl +++ b/src/community/remote-milestones.osl @@ -1,3 +1,9 @@ +export { + remoteMilestoneChecks +} + +auto remoteMilestoneChecks = cache.new({capacity: 10000, ttl: 10000}) + // Use existing public Rotur APIs. Counts, recipients and links never come // from the caller. This records milestones observed through MistWarp. def remoteMilestoneData(string path) object ( diff --git a/report-evidence.osl b/src/community/report-evidence.osl similarity index 97% rename from report-evidence.osl rename to src/community/report-evidence.osl index ae3672b..6b061fa 100644 --- a/report-evidence.osl +++ b/src/community/report-evidence.osl @@ -1,3 +1,10 @@ +export { + handleGetEvidenceAsset, + handleGetEvidenceJson, + loadEvidenceMeta, + maybeClearProjectEvidence +} + def evidenceDirFor(string id) string ( return reportEvidenceDir ++ id ++ "/" ) diff --git a/reports.osl b/src/community/reports.osl similarity index 99% rename from reports.osl rename to src/community/reports.osl index b947139..712e4f0 100644 --- a/reports.osl +++ b/src/community/reports.osl @@ -1,3 +1,13 @@ +export { + anonymiseReportsBy, + closeRoturReport, + fileUnfiledRoturReports, + loadReports, + reportedContent, + saveReports, + syncEscalatedRoturReports +} + def loadReports() array ( auto res = readFile(reportsFile) if !res.ok ( diff --git a/src/community/routes.osl b/src/community/routes.osl new file mode 100644 index 0000000..ad0922c --- /dev/null +++ b/src/community/routes.osl @@ -0,0 +1,143 @@ +export { + registerCommunityRoutes, + registerCommunityUploadRoutes +} + +def registerCommunityUploadRoutes(*serve.Router uploads) ( + uploads.POST("/spaces/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetSpaceThumbnail) +) + +def registerCommunityRoutes(*serve.Router api) ( + api.POST("/milestones/followers/:name", requireAuth, handleCheckFollowerMilestones) + api.POST("/milestones/posts/:id", requireAuth, handleCheckPostMilestones) + api.POST("/milestones/themes/:id", requireAuth, handleCheckThemeMilestones) + api.GET("/notifications", requireAuth, handleGetNotifications) + api.POST("/notifications/read", requireAuth, handleReadNotifications) + + api.GET("/me/library", requireAuth, handleGetMyLibrary) + + api.PUT("/me/library/:project/visibility", requireAuth, handleSetLibraryProjectVisibility) + + api.GET("/me/purchases", requireAuth, handleGetMyPurchases) + + api.GET("/groups/:tag", handleGetMistWarpGroup) + + api.GET("/development/pulls", handleDevelopmentPulls) + api.GET("/roadmap", handleListIdeas) + api.POST("/roadmap", requireAuth, requireGoodStanding, handleCreateIdea) + api.POST("/roadmap/:id/vote", requireAuth, handleVoteIdea) + api.PUT("/roadmap/:id", requireAdmin, handleUpdateIdea) + api.GET("/roadmap/:id/comments", handleGetIdeaComments) + api.POST("/roadmap/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateIdeaComment) + api.DELETE("/roadmap/:id/comments/:cid", requireAuth, handleDeleteIdeaComment) + api.PUT("/roadmap/:id/comments/:cid", requireAuth, handleEditIdeaComment) + api.POST("/roadmap/:id/comments/:cid/react", requireAuth, handleReactIdeaComment) + api.GET("/spaces", handleListSpaces) + api.POST("/spaces", requireAuth, requireGoodStanding, handleCreateSpace) + api.GET("/spaces/:id", handleGetSpace) + api.GET("/spaces/:id/manage", requireAuth, handleGetSpaceManagement) + api.PUT("/spaces/:id", requireAuth, handleUpdateSpace) + api.DELETE("/spaces/:id", requireAuth, handleDeleteSpace) + api.POST("/spaces/:id/projects/:project", requireAuth, handleAddSpaceProject) + api.DELETE("/spaces/:id/projects/:project", requireAuth, handleRemoveSpaceProject) + api.POST("/spaces/:id/follow", requireAuth, handleFollowSpace) + api.DELETE("/spaces/:id/follow", requireAuth, handleUnfollowSpace) + api.POST("/spaces/:id/curators", requireAuth, handleInviteSpaceCurator) + api.DELETE("/spaces/:id/curators/:username", requireAuth, handleRemoveSpaceCurator) + api.POST("/spaces/:id/invitations/respond", requireAuth, handleRespondSpaceCuratorInvite) + api.DELETE("/spaces/:id/invitations/:username", requireAuth, handleCancelSpaceCuratorInvite) + api.POST("/spaces/:id/judges", requireAuth, handleInviteChallengeJudge) + api.DELETE("/spaces/:id/judges/:username", requireAuth, handleRemoveChallengeJudge) + api.POST("/spaces/:id/judge-invitations/respond", requireAuth, handleRespondChallengeJudgeInvite) + api.POST("/spaces/:id/join", requireAuth, requireGoodStanding, handleJoinChallenge) + api.DELETE("/spaces/:id/join", requireAuth, handleLeaveChallenge) + api.PUT("/spaces/:id/entries/:project/score", requireAuth, handleScoreChallengeEntry) + api.PUT("/spaces/:id/entries/:project/vote", requireAuth, handleVoteChallengeEntry) + api.POST("/spaces/:id/results", requireAuth, handlePublishChallengeResults) + api.POST("/spaces/:id/react", requireAuth, handleReactSpace) + api.GET("/spaces/:id/comments", handleGetSpaceComments) + api.POST("/spaces/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateSpaceComment) + api.DELETE("/spaces/:id/comments/:cid", requireAuth, handleDeleteSpaceComment) + api.PUT("/spaces/:id/comments/:cid", requireAuth, handleEditSpaceComment) + api.POST("/spaces/:id/comments/:cid/react", requireAuth, handleReactSpaceComment) + api.POST("/spaces/:id/comments/:cid/pin", requireAuth, handlePinSpaceComment) + api.GET("/me/spaces", requireAuth, handleMySpaces) + + api.GET("/projects/:id/card.svg", handleProjectCard) + api.GET("/projects/:id/card.png", handleProjectSharePng) + + api.POST("/projects/:id/save", requireAuth, handleSaveProject) + api.DELETE("/projects/:id/save", requireAuth, handleUnsaveProject) + + api.POST("/projects/:id/preview", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreatePreview) + api.GET("/projects/:id/releases", handleListReleases) + api.GET("/projects/:id/releases/:release/project.json", handleGetReleaseProjectJson) + api.POST("/projects/:id/releases", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreateRelease) + api.DELETE("/projects/:id/releases/:release", requireAuth, handleDeleteRelease) + api.POST("/projects/:id/contribute", requireAuth, handleCreateContribution) + api.POST("/projects/:id/diagnostics", serve.limitRequests(120, {window: 60000}), handleRecordDiagnostic) + api.GET("/projects/:id/diagnostics", requireAuth, handleGetDiagnostics) + api.POST("/projects/:id/feedback", requireAuth, requireGoodStanding, serve.limitRequests(20, {window: 3600000}), handleCreateProjectFeedback) + api.GET("/projects/:id/feedback", requireAuth, handleGetProjectFeedback) + api.PUT("/projects/:id/feedback/:feedback", requireAuth, handleUpdateProjectFeedback) + api.GET("/projects/:id/reviews", handleListProjectReviews) + api.PUT("/projects/:id/reviews/me", requireAuth, requireGoodStanding, handleSaveProjectReview) + api.DELETE("/projects/:id/reviews/me", requireAuth, handleDeleteProjectReview) + + api.GET("/projects/:id/comments", handleGetProjectComments) + api.POST("/projects/:id/comments/donation/intent", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleProjectCommentDonationIntent) + api.POST("/projects/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateProjectComment) + api.DELETE("/projects/:id/comments/:cid", requireAuth, handleDeleteProjectComment) + api.PUT("/projects/:id/comments/:cid", requireAuth, handleEditProjectComment) + api.POST("/projects/:id/comments/:cid/react", requireAuth, handleReactProjectComment) + api.POST("/projects/:id/comments/:cid/pin", requireAuth, handlePinProjectComment) + + api.GET("/bounties/:id/activity", handleGetBountyActivity) + api.POST("/bounties/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateBountyComment) + api.PUT("/bounties/:id/comments/:cid", requireAuth, handleEditBountyComment) + api.DELETE("/bounties/:id/comments/:cid", requireAuth, handleDeleteBountyComment) + api.POST("/bounties/:id/workers/me", requireAuth, requireGoodStanding, handleJoinBounty) + api.DELETE("/bounties/:id/workers/me", requireAuth, handleLeaveBounty) + + api.GET("/search/users", handleSearchUsers) + api.GET("/users/:name", handleGetUser) + api.GET("/users/:name/library", handleGetUserPlaytimeLibrary) + api.GET("/users/:name/reviews", handleListUserReviews) + api.PUT("/me/profile", requireAuth, handleUpdateProfile) + api.POST("/users/:name/follow", requireAuth, handleFollowUser) + api.DELETE("/users/:name/follow", requireAuth, handleUnfollowUser) + api.GET("/users/:name/projects", handleGetUserProjects) + api.GET("/activity", handleGetActivity) + api.GET("/users/:name/followers", handleGetUserFollowers) + api.GET("/users/:name/following", handleGetUserFollowing) + api.GET("/users/:name/comments", handleGetProfileComments) + api.POST("/users/:name/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateProfileComment) + api.DELETE("/users/:name/comments/:cid", requireAuth, handleDeleteProfileComment) + api.PUT("/users/:name/comments/:cid", requireAuth, handleEditProfileComment) + api.POST("/users/:name/comments/:cid/react", requireAuth, handleReactProfileComment) + api.POST("/users/:name/comments/:cid/pin", requireAuth, handlePinProfileComment) + + api.GET("/news", handleGetNews) + api.GET("/news/:id", handleGetNewsItem) + api.POST("/news/:id/view", serve.limitRequests(120, {window: 3600000}), handleViewNews) + api.POST("/news", requireAdmin, handleCreateNews) + api.PUT("/news/:id", requireAdmin, handleUpdateNews) + api.DELETE("/news/:id", requireAdmin, handleDeleteNews) + api.POST("/news/:id/react", requireAuth, handleReactNews) + api.POST("/news/:id/poll", requireAuth, handleVoteNewsPoll) + + api.POST("/report", requireAuth, serve.limitRequests(10, {window: 3600000}), handleCreateReport) + api.POST("/reports", requireAuth, serve.limitRequests(10, {window: 3600000}), handleCreateReport) + api.POST("/support", serve.limitRequests(5, {window: 3600000}), handleCreateSupportRequest) + + api.GET("/admin/reports", requireAdmin, handleGetReports) + api.GET("/admin/reports/evidence/:id", requireAdmin, handleGetReportEvidence) + api.POST("/admin/reports/resolve", requireAdmin, handleResolveReport) + + api.POST("/admin/reports/escalate", requireAdmin, handleEscalateReport) + + api.POST("/admin/standing", requireAdmin, handleSetStanding) + + api.POST("/admin/user/message", requireAdmin, handleMessageUser) + api.POST("/admin/user/profile", requireAdmin, handleAdminUpdateProfile) +) diff --git a/social.osl b/src/community/social.osl similarity index 99% rename from social.osl rename to src/community/social.osl index 4a6b783..4b00137 100644 --- a/social.osl +++ b/src/community/social.osl @@ -1,3 +1,12 @@ +export { + addPurchaseRecord, + addToLibrary, + isInLibrary, + ownerIndexCount, + ownerIndexEntries, + usernameIsMinor +} + def sortProjectsByEditedDesc(array items) array ( array sorted = [] for i items.len ( diff --git a/standing.osl b/src/community/standing.osl similarity index 98% rename from standing.osl rename to src/community/standing.osl index 8b2ee26..bf54e71 100644 --- a/standing.osl +++ b/src/community/standing.osl @@ -1,3 +1,10 @@ +export { + requireGoodStanding, + setStanding, + standingInfo, + standingLevel +} + def standingRank(string level) number ( if level == "warning" ( return 1 diff --git a/src/config/config.osl b/src/config/config.osl new file mode 100644 index 0000000..8219c38 --- /dev/null +++ b/src/config/config.osl @@ -0,0 +1,241 @@ +export { + activityDir, + adminUsers, + adminsFile, + agreementAcceptDir, + appURL, + assetsIndexFile, + backpackDir, + bansFile, + cardsDir, + commentsDir, + commerceServiceKey, + configValue, + editorOrigin, + extensionsDir, + extensionsFile, + gameEntitlementsDir, + gameInventoryDir, + gameSaveUsageDir, + gameSavesDir, + giteaAdminToken, + giteaURL, + githubOrg, + githubToken, + historyMigrationWorkers, + indexFile, + legacyAccountGameDataDir, + localBlobsDir, + maxAssetCount, + maxExtensionSourceBytes, + maxProjectExtensionBytes, + maxProjectExtensions, + maxProjectJsonBytes, + maxStoredProjectJsonBytes, + maxThumbBytes, + maxUploadChunkBytes, + maxWorkspaceBytes, + maxWorkspaceFiles, + mistwarpRoturToken, + mistwarpRoturUser, + newsFile, + notificationsDir, + port, + projectsDir, + purchaseKeysFile, + quotaDir, + quotaWindowMs, + r2AccessKeyId, + r2Bucket, + r2Client, + r2Endpoint, + r2Local, + r2PublicBase, + r2SecretAccessKey, + realtimeURL, + releasesDir, + reportEvidenceDir, + reportsDir, + reportsFile, + roturAppKey, + roturBase, + safetyDir, + sessionsDir, + shadowBansFile, + stagingDir, + stripeClassroomPrice, + stripeClassroomPriceYearly, + stripeSeatPackPrice, + stripeSeatPackPriceYearly, + stripeSecretKey, + stripeWebhookSecret, + thumbnailSecretFile, + thumbnailsDir, + tmpDir, + trashDir, + uploadDebounceMs, + userIdsFile, + userSettingsDir, + usersDir +} + +def dotenvValue(string name) string ( + if !fs.exists(".env") ( + return "" + ) + string contents = fs.readFile(".env") catch (err) ( + log "WARNING: could not read .env: " ++ err.toStr() + return "" + ) + array lines = contents.split("\n") + for i lines.len ( + string line = lines[i].toStr().strip() + if line.startsWith("export ") ( + line = line.replace("export ", "").strip() + ) + if line != "" and !line.startsWith("#") and line.contains("=") ( + array parts = line.split("=") + string key = parts[1].toStr().strip() + if key == name ( + string value = parts[2].toStr().strip() + for p parts.len ( + if p > 2 ( + value ++= "=" ++ parts[p].toStr() + ) + ) + return value.replace("\"", "").replace("'", "") + ) + ) + ) + return "" +) + +def envValue(string name) string ( + string value = env.get(name, "") catch (err) ( + log "WARNING: could not read " ++ name ++ " from the environment: " ++ err.toStr() + return "" + ) + return value +) + +// Configuration is looked up in this order: +// 1. MISTWARP_ in the real environment (an explicit, app-specific override) +// 2. this app's .env +// 3. in the real environment (for deployments that don't ship a .env) +// +// Inherited variables come last on purpose. The API is often started from a +// shared shell that exports another project's variables (for example Rotur's +// R2_ACCESS_KEY_ID and R2_PUBLIC_BASE), and those must not silently replace +// this service's own configuration. +def configValue(string name, string defaultValue) string ( + string value = envValue("MISTWARP_" ++ name) + if value != "" ( + return value + ) + value = dotenvValue(name) + if value != "" ( + return value + ) + value = envValue(name) + if value != "" ( + return value + ) + return defaultValue +) + +string projectsDir = "data/projects/" +string indexFile = "data/projects-index.json" +string assetsIndexFile = "data/assets-index.json" +string usersDir = "data/users/" +string sessionsDir = "data/sessions/" +string commentsDir = "data/comments/" +string reportsDir = "data/reports/" +string reportsFile = reportsDir ++ "reports.json" +string reportEvidenceDir = "data/report-evidence/" +string newsFile = "data/news.json" +string notificationsDir = "data/notifications/" +string activityDir = "data/activity/" +string userSettingsDir = "data/usersettings/" +string tmpDir = "data/tmp/" +string stagingDir = "data/staging/" +string thumbnailsDir = "data/thumbnails/" +string cardsDir = "data/cards/" +string thumbnailSecretFile = "data/thumbnail-secret" +string quotaDir = "data/quota/" +string userIdsFile = "data/userids.json" +string adminsFile = "data/admins.json" +string bansFile = "data/bans.json" +string shadowBansFile = "data/shadowbans.json" +string agreementAcceptDir = "data/agreement-acceptances/" +string extensionsDir = "data/extensions/" +string releasesDir = "data/releases/" +string extensionsFile = "data/extensions.json" +string safetyDir = "data/safety/" +string trashDir = "data/trash/" +string gameSavesDir = "data/game-saves/" +string gameSaveUsageDir = "data/game-save-usage/" +string legacyAccountGameDataDir = "data/account-game-data/" +string gameEntitlementsDir = "data/game-entitlements/" +string gameInventoryDir = "data/game-inventory/" +string backpackDir = "data/backpack/" + +number maxWorkspaceBytes = 536870912 +number maxWorkspaceFiles = 1000000 +number maxUploadChunkBytes = 8388608 + +number maxProjectJsonBytes = 268435456 +number maxStoredProjectJsonBytes = 20971520 +number maxThumbBytes = 1048576 +number maxAssetCount = 2000 +number quotaWindowMs = 604800000 +number uploadDebounceMs = 604800000 +number maxProjectExtensions = 100 +number maxExtensionSourceBytes = 2097152 +number maxProjectExtensionBytes = 10485760 + +string appURL = configValue("APP_URL", "https://api.mistwarp.org") +string port = configValue("PORT", "5627") +string roturAppKey = configValue("ROTUR_APP_KEY", "mistwarp") +string r2Endpoint = configValue("R2_ENDPOINT", "") +string r2Bucket = configValue("R2_BUCKET", "mistwarp") +string r2AccessKeyId = configValue("R2_ACCESS_KEY_ID", "") +string r2SecretAccessKey = configValue("R2_SECRET_ACCESS_KEY", "") +string r2PublicBase = configValue("R2_PUBLIC_BASE", "") +string giteaURL = configValue("GITEA_URL", "https://git.rotur.dev") +string giteaAdminToken = configValue("GITEA_ADMIN_TOKEN", "") +string githubOrg = configValue("GITHUB_ORG", "MistWarp") +string githubToken = configValue("GITHUB_TOKEN", "") +string editorOrigin = configValue("EDITOR_ORIGIN", "https://mistwarp.org") +array adminUsers = configValue("ADMIN_USERS", "mist").toLower().split(",") +string mistwarpRoturToken = configValue("MISTWARP_ROTUR_TOKEN", "") +string mistwarpRoturUser = configValue("MISTWARP_ROTUR_USER", "") +string roturBase = configValue("ROTUR_BASE", "https://api.rotur.dev") +string commerceServiceKey = configValue("COMMERCE_SERVICE_KEY", "") +string stripeSecretKey = configValue("STRIPE_SECRET_KEY", "") +string stripeWebhookSecret = configValue("STRIPE_WEBHOOK_SECRET", "") +string stripeClassroomPrice = configValue("STRIPE_CLASSROOM_PRICE", "") +string stripeSeatPackPrice = configValue("STRIPE_SEAT_PACK_PRICE", "") +string stripeClassroomPriceYearly = configValue("STRIPE_CLASSROOM_PRICE_YEARLY", "") +string stripeSeatPackPriceYearly = configValue("STRIPE_SEAT_PACK_PRICE_YEARLY", "") +string realtimeURL = configValue("REALTIME_URL", "wss://api.mistwarp.org/v1/connect") +number historyMigrationWorkers = configValue("HISTORY_MIGRATION_WORKERS", "4").toNum().toInt() +if historyMigrationWorkers < 1 ( + historyMigrationWorkers = 1 +) +if historyMigrationWorkers > 8 ( + historyMigrationWorkers = 8 +) +string purchaseKeysFile = "data/purchase-keys.json" + +boolean r2Local = r2Endpoint == "" or r2AccessKeyId == "" +string localBlobsDir = "data/blobs/" + +*s3.Client r2Client = s3.new({ + "endpoint": if r2Local then "http://127.0.0.1:9" else r2Endpoint, + "bucket": r2Bucket, + "access_key_id": r2AccessKeyId, + "secret_access_key": r2SecretAccessKey, + "region": "auto" +}) + +void fs.mkdir("data/", {recursive: true}) diff --git a/database.osl b/src/core/database.osl similarity index 93% rename from database.osl rename to src/core/database.osl index 1e36ccd..f4c4baf 100644 --- a/database.osl +++ b/src/core/database.osl @@ -1,5 +1,51 @@ import "std:process" +export { + assignmentDocs, + bountyWorkerDocs, + classDocs, + classroomAuditDocs, + classroomClaimDocs, + classroomGroupDocs, + classroomPlanDocs, + classroomPresentationDocs, + classroomTermsDocs, + cleanupExpiredCommunityRecords, + commentIndexDocs, + deleteDocumentsByField, + deleteProjectCommunityRecords, + developmentDocs, + diagnosticDocs, + errorDocs, + feedbackDocs, + funnelDocs, + ideaDocs, + initCommunityDatabase, + likeMilestoneDocs, + playtimeDocs, + playtimeSessionDocs, + previewDocs, + profileSearchDocs, + projectLastPlayed, + projectPlaytime, + projectSearchDocs, + projectTotalPlaytime, + pullDocs, + putDocument, + releaseDocs, + removeSearchProject, + reviewDocs, + schoolDocs, + spaceDocs, + spaceMemberDocs, + studentDocs, + submissionDocs, + syncSearchProfile, + syncSearchProject, + validPreviewKey +} + + // The database and its collections are opened once at startup. A failure here // means the API cannot serve anything, so report the reason and stop instead of // carrying on with a half-initialized database. diff --git a/utils.osl b/src/core/utils.osl similarity index 88% rename from utils.osl rename to src/core/utils.osl index 12785a5..732dc96 100644 --- a/utils.osl +++ b/src/core/utils.osl @@ -1,3 +1,18 @@ +export { + clampNumber, + copyFileReplacing, + extPartOf, + generateCommentId, + generateId, + isSafePathPart, + isValidMd5Ext, + md5PartOf, + normalizeTag, + normalizeUsername, + paginate, + randomString +} + def randomString(number length) string ( return crypto.randomString(length) ) diff --git a/utils.test.osl b/src/core/utils.test.osl similarity index 100% rename from utils.test.osl rename to src/core/utils.test.osl diff --git a/game-data.osl b/src/games/game-data.osl similarity index 98% rename from game-data.osl rename to src/games/game-data.osl index f089e69..64d6cf1 100644 --- a/game-data.osl +++ b/src/games/game-data.osl @@ -1,3 +1,11 @@ +export { + collectGameDataForUser, + purgeGameDataForProject, + purgeGameDataForUser +} + +auto gameDataCapabilityCache = cache.new({capacity: 100000, ttl: 300000}) + number gameSaveMaxBytes = 65536 number gameDataCapabilityTtlMs = 300000 number multiplayerTicketTtlMs = 60000 diff --git a/game-inventory.osl b/src/games/game-inventory.osl similarity index 99% rename from game-inventory.osl rename to src/games/game-inventory.osl index dbe8725..d40dc68 100644 --- a/game-inventory.osl +++ b/src/games/game-inventory.osl @@ -1,3 +1,5 @@ +export {} + def projectGameItems(object project) array ( if project.contains("gameItems") and project.gameItems != null ( return project.gameItems.assertElse(array, []) diff --git a/game-inventory.test.osl b/src/games/game-inventory.test.osl similarity index 98% rename from game-inventory.test.osl rename to src/games/game-inventory.test.osl index 381a118..08ac7a7 100644 --- a/game-inventory.test.osl +++ b/src/games/game-inventory.test.osl @@ -2,7 +2,7 @@ import "std:testing" import "std:sync" import "std:serve" import "std:regex" -import "./request-body.osl" +import "../http/request-body.osl" import "./game-inventory.osl" def isSafePathPart(string value) boolean ( diff --git a/game-marketplace.osl b/src/games/game-marketplace.osl similarity index 99% rename from game-marketplace.osl rename to src/games/game-marketplace.osl index 111ed54..a59d413 100644 --- a/game-marketplace.osl +++ b/src/games/game-marketplace.osl @@ -1,3 +1,7 @@ +export { + completeGameProductPurchase +} + def projectGameProducts(object project) array ( if project.contains("gameProducts") and project.gameProducts != null ( return project.gameProducts.assertElse(array, []) diff --git a/src/games/routes.osl b/src/games/routes.osl new file mode 100644 index 0000000..51be651 --- /dev/null +++ b/src/games/routes.osl @@ -0,0 +1,25 @@ +export { + registerGameRoutes +} + +def registerGameRoutes(*serve.Router api) ( + api.GET("/me/game-saves", requireAuth, handleListMyGameSaves) + api.GET("/me/game-inventory", requireAuth, handleGetMyGameInventory) + + api.DELETE("/me/game-saves/:project", requireAuth, handleDeleteMyGameSave) + + api.POST("/projects/:id/data-capability", requireAuth, handleCreateGameDataCapability) + api.POST("/projects/:id/multiplayer-ticket", requireAuth, handleCreateMultiplayerTicket) + api.GET("/projects/:id/products", handleListGameProducts) + api.GET("/projects/:id/products/:product/owns", requireAuth, handleOwnsGameProduct) + api.POST("/projects/:id/products/:product/purchase/intent", requireAuth, handleGameProductIntent) + api.POST("/projects/:id/products/:product/purchase/confirm", requireAuth, handleGameProductConfirm) + api.POST("/projects/:id/products/:product/grant", requireAuth, handleGrantGameProduct) + api.POST("/projects/:id/products/:product/revoke", requireAuth, handleRevokeGameProduct) + api.POST("/projects/:id/products/:product/refund/intent", requireAuth, handleGameProductRefundIntent) + api.GET("/projects/:id/game-inventory-config", requireAuth, handleGetGameInventoryConfig) + api.GET("/projects/:id/me/inventory", requireAuth, handleGetProjectInventory) + api.POST("/projects/:id/me/inventory/grant", requireAuth, handleGrantProjectItem) + api.GET("/projects/:id/me/save", requireAuth, handleGetMyProjectSave) + api.PUT("/projects/:id/me/save", requireAuth, handlePutMyProjectSave) +) diff --git a/git-inspection.osl b/src/git/git-inspection.osl similarity index 97% rename from git-inspection.osl rename to src/git/git-inspection.osl index a27d4d4..c7dc3a8 100644 --- a/git-inspection.osl +++ b/src/git/git-inspection.osl @@ -1,3 +1,12 @@ +export { + cachedProjectWorkspacePath, + projectRecordsCommit, + projectWorkspaceFingerprint, + validGitObjectId +} + +auto gitInspectionPruneCache = cache.new({capacity: 4, ttl: 300000}) + def projectWorkspaceFingerprint(object project) string ( array keys = projectWorkspaceKeys(project) string identity = "" diff --git a/gitea.osl b/src/git/gitea.osl similarity index 99% rename from gitea.osl rename to src/git/gitea.osl index 9bd0546..b6a3831 100644 --- a/gitea.osl +++ b/src/git/gitea.osl @@ -1,3 +1,5 @@ +export {} + def giteaHeaders(string sudoUser) object ( object headers = { "Authorization": "token " ++ giteaAdminToken, diff --git a/pull-metadata.osl b/src/git/pull-metadata.osl similarity index 98% rename from pull-metadata.osl rename to src/git/pull-metadata.osl index 2c5ffbd..7cab885 100644 --- a/pull-metadata.osl +++ b/src/git/pull-metadata.osl @@ -1,3 +1,7 @@ +export { + canonicalHistoryHead +} + def storedPullCommentCount(object pull) number ( if !pull.contains("commentCount") or pull.commentCount == null ( return -1 diff --git a/pull-metadata.test.osl b/src/git/pull-metadata.test.osl similarity index 100% rename from pull-metadata.test.osl rename to src/git/pull-metadata.test.osl diff --git a/pulls.osl b/src/git/pulls.osl similarity index 99% rename from pulls.osl rename to src/git/pulls.osl index ce11134..1a646e0 100644 --- a/pulls.osl +++ b/src/git/pulls.osl @@ -1,3 +1,14 @@ +export { + canAccessPullWorkspace, + completeNativePull, + createNativePull, + projectBranchHead, + projectGitHead, + projectHasWorkspace, + pullCommentKey, + pullDocument +} + def projectGitHead(object project) string ( return canonicalHistoryHead(project) ) diff --git a/src/git/routes.osl b/src/git/routes.osl new file mode 100644 index 0000000..6e4ee46 --- /dev/null +++ b/src/git/routes.osl @@ -0,0 +1,26 @@ +export { + registerGitRoutes +} + +def registerGitRoutes(*serve.Router api) ( + api.GET("/projects/:id/commits", handleGetCommits) + api.GET("/projects/:id/branches", handleListProjectBranches) + api.POST("/projects/:id/branches", requireAuth, handleCreateProjectBranch) + api.PATCH("/projects/:id/branches", requireAuth, handleRenameProjectBranch) + api.DELETE("/projects/:id/branches", requireAuth, handleDeleteProjectBranch) + api.POST("/projects/:id/branches/merge", requireAuth, handlePrepareBranchMerge) + api.GET("/projects/:id/commits/:sha", handleGetCommitInspection) + + api.GET("/projects/:id/commits/:sha/tree", handleGetCommitTree) + api.GET("/projects/:id/commits/:sha/file", handleGetCommitFile) + api.GET("/projects/:id/pulls", handleListPulls) + api.POST("/projects/:id/pulls", requireAuth, handleCreatePull) + api.GET("/projects/:id/pulls/:index", handleGetPull) + api.GET("/projects/:id/pulls/:index/timeline", handleGetPullTimeline) + api.POST("/projects/:id/pulls/:index/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreatePullComment) + api.PUT("/projects/:id/pulls/:index/comments/:cid", requireAuth, handleEditPullComment) + api.DELETE("/projects/:id/pulls/:index/comments/:cid", requireAuth, handleDeletePullComment) + api.GET("/projects/:id/pulls/:index/diff", requireAuth, handleGetPullDiff) + api.POST("/projects/:id/pulls/:index/merge", requireAuth, handleMergePull) + api.POST("/projects/:id/pulls/:index/close", requireAuth, handleClosePull) +) diff --git a/commit-management.osl b/src/history/commit-management.osl similarity index 99% rename from commit-management.osl rename to src/history/commit-management.osl index 8dd7596..9ab7709 100644 --- a/commit-management.osl +++ b/src/history/commit-management.osl @@ -1,3 +1,5 @@ +export {} + def projectCommitMetadata(object project) object ( if project.contains("commitMetadata") and project.commitMetadata != null ( return project.commitMetadata.assertElse(object, {}) diff --git a/history-delta.osl b/src/history/history-delta.osl similarity index 99% rename from history-delta.osl rename to src/history/history-delta.osl index 2047166..ff25cd1 100644 --- a/history-delta.osl +++ b/src/history/history-delta.osl @@ -1,3 +1,7 @@ +export { + graphBranchLog +} + def appendUniqueCommit(array target, object seen, object commit, number limit) array ( string sha = commit.sha.toStr() if sha != "" and !seen.contains(sha) and target.len < limit ( diff --git a/history-delta.test.osl b/src/history/history-delta.test.osl similarity index 100% rename from history-delta.test.osl rename to src/history/history-delta.test.osl diff --git a/history-migration.osl b/src/history/history-migration.osl similarity index 98% rename from history-migration.osl rename to src/history/history-migration.osl index 14f0421..a7acc2e 100644 --- a/history-migration.osl +++ b/src/history/history-migration.osl @@ -1,3 +1,10 @@ +export { + ensureGeneratedProjectHistory, + ensureUploadedProjectHistory, + generateMissingProjectHistories, + projectHasStoredContent +} + def writeGeneratedHistoryArchive(string projectJsonPath, string assetsPath, string outputPath, string projectId, string owner, number createdAt, string parentArchivePath, string parentHead, string parentId) string ( return gitinspection.GenerateHistoryArchiveJSON(projectJsonPath, assetsPath, outputPath, projectId, owner, createdAt, parentArchivePath, parentHead, parentId) ) diff --git a/history-migration.test.osl b/src/history/history-migration.test.osl similarity index 100% rename from history-migration.test.osl rename to src/history/history-migration.test.osl diff --git a/src/history/routes.osl b/src/history/routes.osl new file mode 100644 index 0000000..a2c8352 --- /dev/null +++ b/src/history/routes.osl @@ -0,0 +1,9 @@ +export { + registerHistoryRoutes +} + +def registerHistoryRoutes(*serve.Router api) ( + api.PATCH("/projects/:id/commits/:sha", requireAuth, handlePatchCommit) + api.GET("/projects/:id/commits/:sha/co-authors", handleGetCommitCollaborators) + api.GET("/projects/:id/commits/:sha/collaborators", handleGetCommitCollaborators) +) diff --git a/cache-policy.osl b/src/http/cache-policy.osl similarity index 80% rename from cache-policy.osl rename to src/http/cache-policy.osl index 95d7e35..eb29e5f 100644 --- a/cache-policy.osl +++ b/src/http/cache-policy.osl @@ -1,3 +1,8 @@ +export { + projectWorkspaceEtag, + shouldPruneGitInspectionCache +} + def shouldPruneGitInspectionCache(number callCount) boolean ( return callCount > 0 and ((callCount - 1) % 32) == 0 ) diff --git a/cache-policy.test.osl b/src/http/cache-policy.test.osl similarity index 100% rename from cache-policy.test.osl rename to src/http/cache-policy.test.osl diff --git a/middleware.osl b/src/http/middleware.osl similarity index 96% rename from middleware.osl rename to src/http/middleware.osl index 4bea83b..fc72934 100644 --- a/middleware.osl +++ b/src/http/middleware.osl @@ -1,3 +1,10 @@ +export { + authMiddleware, + editorSessionGuard, + requireAdmin, + requireAuth +} + def authMiddleware(*serve.Context c) ( boolean authenticated = false string username = "" diff --git a/request-body.osl b/src/http/request-body.osl similarity index 87% rename from request-body.osl rename to src/http/request-body.osl index 2c43e41..ed4b06c 100644 --- a/request-body.osl +++ b/src/http/request-body.osl @@ -1,3 +1,9 @@ +export { + contextBool, + contextString, + requestJSON +} + def requestJSON(*serve.Context c) object ( object body = c.bodyJSON() catch (err) ( if err.kind == "ParseError" ( diff --git a/request-limits.osl b/src/http/request-limits.osl similarity index 59% rename from request-limits.osl rename to src/http/request-limits.osl index 4e370e2..f64f7e4 100644 --- a/request-limits.osl +++ b/src/http/request-limits.osl @@ -1,3 +1,14 @@ +export { + maxAppRequestBytes, + maxBackpackRequestBytes, + maxExtensionIndexRequestBytes, + maxProjectUploadRequestBytes, + maxStandardRequestBytes, + maxThumbnailRequestBytes, + maxUploadChunkRequestBytes, + maxWorkspaceBootstrapRequestBytes +} + number maxAppRequestBytes = 1073741824 number maxStandardRequestBytes = 262144 number maxProjectUploadRequestBytes = 1073741824 diff --git a/request-limits.test.osl b/src/http/request-limits.test.osl similarity index 100% rename from request-limits.test.osl rename to src/http/request-limits.test.osl diff --git a/extensions.osl b/src/projects/extensions.osl similarity index 99% rename from extensions.osl rename to src/projects/extensions.osl index a3d2e18..0866c23 100644 --- a/extensions.osl +++ b/src/projects/extensions.osl @@ -1,3 +1,10 @@ +export { + migrateExtensionUsage, + projectBlockedExtension, + storeProjectExtensions, + storedProjectExtensions +} + def loadExtensionPolicy() object ( auto res = readFile(extensionsFile) if !res.ok ( diff --git a/fork-visibility.test.osl b/src/projects/fork-visibility.test.osl similarity index 100% rename from fork-visibility.test.osl rename to src/projects/fork-visibility.test.osl diff --git a/live-collaboration-policy.osl b/src/projects/live-collaboration-policy.osl similarity index 98% rename from live-collaboration-policy.osl rename to src/projects/live-collaboration-policy.osl index ec34aab..229acdb 100644 --- a/live-collaboration-policy.osl +++ b/src/projects/live-collaboration-policy.osl @@ -1,3 +1,5 @@ +export {} + def isSharedEditorRole(string role) boolean ( return role == "owner" or role == "maintainer" or role == "editor" ) diff --git a/live-collaboration-policy.test.osl b/src/projects/live-collaboration-policy.test.osl similarity index 100% rename from live-collaboration-policy.test.osl rename to src/projects/live-collaboration-policy.test.osl diff --git a/live-collaboration.osl b/src/projects/live-collaboration.osl similarity index 99% rename from live-collaboration.osl rename to src/projects/live-collaboration.osl index c6df7d7..0feb977 100644 --- a/live-collaboration.osl +++ b/src/projects/live-collaboration.osl @@ -1,5 +1,8 @@ import "./live-collaboration-policy.osl" +export {} + + // Temporary approvals are leases; explicitly shared Editor access persists. auto liveEditorSessions = cache.new({capacity: 10000, ttl: 90000}) auto projectEditorPresence = cache.new({capacity: 10000, ttl: 90000}) diff --git a/monetize.osl b/src/projects/monetize.osl similarity index 97% rename from monetize.osl rename to src/projects/monetize.osl index 278b685..6126078 100644 --- a/monetize.osl +++ b/src/projects/monetize.osl @@ -1,3 +1,20 @@ +export { + awardContributionBounty, + canSeeInsideProject, + completeProjectPurchase, + loadPurchaseKeys, + projectCommerceSplits, + projectIsViewable, + projectRevenue, + projectSaves, + projectVisibility, + prunePurchaseKeys, + refuseOldPaymentClient, + renameUserMonetization, + savePurchaseKeys, + sourcePolicyAllowsViewer +} + def projectVisibility(object project) string ( if project.contains("visibility") and project.visibility != null ( string v = project.visibility.toStr() diff --git a/project-sharing-policy.osl b/src/projects/project-sharing-policy.osl similarity index 82% rename from project-sharing-policy.osl rename to src/projects/project-sharing-policy.osl index c441207..af1edd0 100644 --- a/project-sharing-policy.osl +++ b/src/projects/project-sharing-policy.osl @@ -1,3 +1,7 @@ +export { + projectSourcePolicyAllows +} + def projectSourcePolicyAllows(object project, boolean isOwner) boolean ( if isOwner ( return true diff --git a/project-sharing-policy.test.osl b/src/projects/project-sharing-policy.test.osl similarity index 100% rename from project-sharing-policy.test.osl rename to src/projects/project-sharing-policy.test.osl diff --git a/projects.osl b/src/projects/projects.osl similarity index 99% rename from projects.osl rename to src/projects/projects.osl index 72fb33a..f4f64af 100644 --- a/projects.osl +++ b/src/projects/projects.osl @@ -1,3 +1,42 @@ +export { + bannedKeyMatches, + canEditProject, + canViewProject, + canViewProjectJson, + cleanupExpiredStagedUploadParts, + deleteOrphanedAssets, + ensureProjectJsonSnapshot, + ensureProjectWorkspaceSnapshots, + ensureViewKey, + forkFailureResponse, + forkProjectFor, + ownerStorageBytes, + pendingUploadEvents, + projectJsonAccess, + projectMustStayPrivate, + projectResponse, + projectRole, + projectVersionHistory, + purgeProject, + purgeProjects, + quotaPath, + removeProjectStorageKeyIfOrphaned, + removeString, + roturMembershipUrl, + roturStorageFullResponse, + sharedProjectsCache, + storageDetails, + storageFullResponse, + sweepStagedProjects, + trashedProjectsOwnedBy, + validateUploadedHistory, + vanityLapseLoop +} + +auto viewDedup = cache.new({capacity: 100000, ttl: 21600000}) +auto viewIndexFlush = cache.new({capacity: 100000, ttl: 600000}) +auto sharedProjectsCache = cache.new({capacity: 2, ttl: 60000}) + def removeString(array items, string value) array ( array next = [] for i items.len ( diff --git a/random-projects.osl b/src/projects/random-projects.osl similarity index 97% rename from random-projects.osl rename to src/projects/random-projects.osl index 94e1adf..29165b5 100644 --- a/random-projects.osl +++ b/src/projects/random-projects.osl @@ -1,3 +1,5 @@ +export {} + def randomProjectEntry(array entries, string exclude) object ( array candidates = [] for i entries.len ( diff --git a/random-projects.test.osl b/src/projects/random-projects.test.osl similarity index 100% rename from random-projects.test.osl rename to src/projects/random-projects.test.osl diff --git a/related-projects.osl b/src/projects/related-projects.osl similarity index 99% rename from related-projects.osl rename to src/projects/related-projects.osl index 53041c6..1416b38 100644 --- a/related-projects.osl +++ b/src/projects/related-projects.osl @@ -1,3 +1,5 @@ +export {} + def relatedProjectEntries(array entries, string id, array excluded, number limit) array ( object seen = {} seen[id] = true diff --git a/related-projects.test.osl b/src/projects/related-projects.test.osl similarity index 100% rename from related-projects.test.osl rename to src/projects/related-projects.test.osl diff --git a/release-access-policy.osl b/src/projects/release-access-policy.osl similarity index 95% rename from release-access-policy.osl rename to src/projects/release-access-policy.osl index 1655485..326a60e 100644 --- a/release-access-policy.osl +++ b/src/projects/release-access-policy.osl @@ -1,3 +1,7 @@ +export { + publicReleaseRecord +} + def keyedJsonUrl(string url, object access) string ( if url == "" or access.allowed != true ( return "" diff --git a/release-access-policy.test.osl b/src/projects/release-access-policy.test.osl similarity index 100% rename from release-access-policy.test.osl rename to src/projects/release-access-policy.test.osl diff --git a/src/projects/routes.osl b/src/projects/routes.osl new file mode 100644 index 0000000..30341cc --- /dev/null +++ b/src/projects/routes.osl @@ -0,0 +1,65 @@ +export { + registerProjectRoutes, + registerProjectUploadRoutes +} + +def registerProjectUploadRoutes(*serve.Router uploads) ( + uploads.POST("/projects/:id/upload", serve.maxBodySize(maxProjectUploadRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProject) + uploads.POST("/projects/:id/upload-parts/:token/chunk", serve.maxBodySize(maxUploadChunkRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProjectPartChunk) + uploads.POST("/projects/:id/history/bootstrap", serve.maxBodySize(maxWorkspaceBootstrapRequestBytes), requireAuth, requireCurrentAgreement, handleBootstrapProjectHistory) + uploads.POST("/projects/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetThumbnail) + + uploads.POST("/admin/projects/:id/extensions/index", serve.maxBodySize(maxExtensionIndexRequestBytes), requireAdmin, handleAdminIndexProjectExtensions) +) + +def registerProjectRoutes(*serve.Router api) ( + api.GET("/me/quota", requireAuth, handleGetMyQuota) + api.GET("/me/stats", requireAuth, handleGetMyStats) + + api.GET("/me/shared-projects", requireAuth, handleGetSharedProjects) + + api.GET("/me/trash", requireAuth, handleGetMyTrash) + + api.GET("/explore", handleExplore) + api.GET("/vanity/:slug", handleResolveVanityProject) + + api.GET("/leaderboard", handleLeaderboard) + + api.POST("/projects", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreateProject) + api.POST("/projects/:id/live", requireAuth, serve.limitRequests(120, {window: 60000}), handleLiveCollaboration) + api.POST("/projects/:id/assets/check", requireAuth, handleCheckProjectAssets) + api.POST("/projects/:id/upload-parts", requireAuth, requireCurrentAgreement, handleCreateProjectUploadPart) + api.POST("/projects/:id/upload-parts/:token/complete", requireAuth, requireCurrentAgreement, handleCompleteProjectUploadPart) + api.GET("/projects/random", handleRandomProject) + api.GET("/projects/featured", handleFeaturedProject) + api.GET("/projects/:id/related", handleRelatedProjects) + api.GET("/sitemap.xml", handleSitemap) + api.GET("/projects/:id/project.json", handleGetProjectJson) + api.GET("/projects/:id/trusted-extensions", handleGetProjectTrustedExtensions) + + api.GET("/projects/:id/extensions/:hash/source", handleGetProjectExtensionSource) + api.GET("/projects/:id/editor", handleGetEditorProject) + api.GET("/projects/:id", handleGetProject) + + api.PUT("/projects/:id", requireAuth, handleUpdateProject) + api.DELETE("/projects/:id", requireAuth, handleDeleteProject) + api.POST("/projects/:id/restore", requireAuth, handleRestoreProject) + api.DELETE("/trash/:id", requireAuth, handlePurgeTrashedProject) + api.POST("/projects/:id/publish", requireAuth, requireGoodStanding, handlePublishProject) + api.POST("/projects/:id/unpublish", requireAuth, handleUnpublishProject) + api.POST("/projects/:id/visibility", requireAuth, requireGoodStanding, handleSetVisibility) + api.POST("/projects/:id/purchase/intent", handleProjectSalesEnded) + api.POST("/projects/:id/purchase/confirm", handleProjectSalesEnded) + api.POST("/projects/:id/view", handleViewProject) + + api.POST("/projects/:id/react", requireAuth, handleReactProject) + api.GET("/projects/:id/remixes", handleGetRemixes) + api.GET("/projects/:id/remixtree", handleGetRemixTree) + api.POST("/projects/:id/remix", requireAuth, serve.limitRequests(30, {window: 3600000}), handleRemixProject) + + api.GET("/admin/extensions", requireAdmin, handleAdminGetExtensions) + + api.GET("/admin/extensions/:hash/source", requireAdmin, handleAdminGetExtensionSource) + api.POST("/admin/extensions/policy", requireAdmin, handleAdminSetExtensionPolicy) + api.POST("/admin/extensions/url-policy", requireAdmin, handleAdminSetExtensionUrlPolicy) +) diff --git a/search-ranking.osl b/src/projects/search-ranking.osl similarity index 95% rename from search-ranking.osl rename to src/projects/search-ranking.osl index 2110167..f2d1e85 100644 --- a/search-ranking.osl +++ b/src/projects/search-ranking.osl @@ -1,3 +1,9 @@ +export { + searchMatchScore, + searchRank, + spaceSearchScore +} + def searchMatchScore(string text, string query) number ( string value = text.toLower().strip() if value == "" or query == "" ( diff --git a/search-ranking.test.osl b/src/projects/search-ranking.test.osl similarity index 100% rename from search-ranking.test.osl rename to src/projects/search-ranking.test.osl diff --git a/game-realtime.osl b/src/realtime/game-realtime.osl similarity index 97% rename from game-realtime.osl rename to src/realtime/game-realtime.osl index 3bd3f53..185a0ca 100644 --- a/game-realtime.osl +++ b/src/realtime/game-realtime.osl @@ -1,3 +1,12 @@ +export { + multiplayerTicketCache, + realtimeUpgradeGuard, + setupGameRealtime +} + +auto multiplayerTicketCache = cache.new({capacity: 100000, ttl: 120000}) +auto realtimeHandshakeCache = cache.new({capacity: 100000, ttl: 30000}) + def realtimeRoomKey(object identity) string ( return identity.projectId.toStr() ++ ":" ++ identity.projectVersion.toStr() ++ ":" ++ identity.context.toStr() ++ ":" ++ identity.room.toStr() ) diff --git a/site-realtime.osl b/src/realtime/site-realtime.osl similarity index 99% rename from site-realtime.osl rename to src/realtime/site-realtime.osl index 7f86f4b..3bafc5b 100644 --- a/site-realtime.osl +++ b/src/realtime/site-realtime.osl @@ -1,3 +1,9 @@ +export { + setupSiteRealtime, + siteBroadcastProject, + siteBroadcastStats +} + def siteSend(*ws.Connection conn, object message) ( conn.Send(json.stringify(message)) ) diff --git a/perks.osl b/src/rotur/perks.osl similarity index 96% rename from perks.osl rename to src/rotur/perks.osl index f8aca0a..e55ee3d 100644 --- a/perks.osl +++ b/src/rotur/perks.osl @@ -1,3 +1,18 @@ +export { + mistwarpBenefitsForProject, + mistwarpBenefitsForTier, + mistwarpBenefitsForUserId, + mistwarpBenefitsForUsername, + roturTierLookup, + trimDayHistory, + trimTimedEntries, + vanityEligibility +} + +auto paidPerksCache = cache.new({capacity: 10000, ttl: 300000}) +auto lastKnownTiers = cache.new({capacity: 10000, ttl: 2592000000}) +auto roturTierUnreachable = cache.new({capacity: 10000, ttl: 60000}) + def roturTierForUser(string userId) string ( return roturTierLookup(userId).tier.toStr() ) diff --git a/rotur-app.osl b/src/rotur/rotur-app.osl similarity index 97% rename from rotur-app.osl rename to src/rotur/rotur-app.osl index 36fc012..33f455e 100644 --- a/rotur-app.osl +++ b/src/rotur/rotur-app.osl @@ -1,3 +1,10 @@ +export { + roturApp, + roturAppReady, + roturClientId, + roturUserRef +} + // MistWarp's Rotur App, called with its client ID and secret: // https://docs.rotur.dev/build-an-app/apps-api // diff --git a/rotur-badges.osl b/src/rotur/rotur-badges.osl similarity index 98% rename from rotur-badges.osl rename to src/rotur/rotur-badges.osl index e1f236d..90f4c73 100644 --- a/rotur-badges.osl +++ b/src/rotur/rotur-badges.osl @@ -1,3 +1,8 @@ +export { + recordCreatingDay, + roturBadgeEvent +} + // Badges on people's Rotur profiles, given by MistWarp's Rotur App: // https://docs.rotur.dev/build-an-app/badges // diff --git a/rotur-badges.test.osl b/src/rotur/rotur-badges.test.osl similarity index 100% rename from rotur-badges.test.osl rename to src/rotur/rotur-badges.test.osl diff --git a/rotur-payments.osl b/src/rotur/rotur-payments.osl similarity index 98% rename from rotur-payments.osl rename to src/rotur/rotur-payments.osl index 53dca44..ee20d26 100644 --- a/rotur-payments.osl +++ b/src/rotur/rotur-payments.osl @@ -1,3 +1,12 @@ +export { + forgetPurchase, + mistwarpFeeAccount, + paidPurchase, + startPurchase +} + +auto roturAppOwnerCache = cache.new({capacity: 4, ttl: 3600000}) + // Payments through Rotur payment requests: // https://docs.rotur.dev/build-an-app/payments // diff --git a/rotur-payments.test.osl b/src/rotur/rotur-payments.test.osl similarity index 99% rename from rotur-payments.test.osl rename to src/rotur/rotur-payments.test.osl index 3b4ba18..ca36d9d 100644 --- a/rotur-payments.test.osl +++ b/src/rotur/rotur-payments.test.osl @@ -9,7 +9,6 @@ string dir = "/tmp/mistwarp-rotur-payments-" ++ timestamp.toStr() ++ "/" void fs.mkdir(dir, {recursive: true}) string purchaseKeysFile = dir ++ "purchase-keys.json" string mistwarpRoturUser = "" -auto roturAppOwnerCache = cache.new({capacity: 4, ttl: 3600000}) def readFile(string path) object ( if !fs.exists(path) ( diff --git a/rotur-plan.osl b/src/rotur/rotur-plan.osl similarity index 98% rename from rotur-plan.osl rename to src/rotur/rotur-plan.osl index 9dad3bb..d6f101c 100644 --- a/rotur-plan.osl +++ b/src/rotur/rotur-plan.osl @@ -1,3 +1,5 @@ +export {} + def normalizeRoturTier(string rawTier) string ( string tier = rawTier.toLower().strip() if tier == "lite" ( diff --git a/rotur-plan.test.osl b/src/rotur/rotur-plan.test.osl similarity index 100% rename from rotur-plan.test.osl rename to src/rotur/rotur-plan.test.osl diff --git a/rotur-signals.osl b/src/rotur/rotur-signals.osl similarity index 95% rename from rotur-signals.osl rename to src/rotur/rotur-signals.osl index 76508af..c44ba92 100644 --- a/rotur-signals.osl +++ b/src/rotur/rotur-signals.osl @@ -1,3 +1,12 @@ +export { + commentRefusal, + isMessageNotification, + roturMessageRefusal, + roturPurchaseRefusal +} + +auto roturMessageSignals = cache.new({capacity: 10000, ttl: 60000}) + // Rotur's safety signals: https://docs.rotur.dev/build-an-app/safety // // MistWarp declares that people can talk to each other and can spend credits, diff --git a/rotur-signals.test.osl b/src/rotur/rotur-signals.test.osl similarity index 96% rename from rotur-signals.test.osl rename to src/rotur/rotur-signals.test.osl index 0e2094e..0836718 100644 --- a/rotur-signals.test.osl +++ b/src/rotur/rotur-signals.test.osl @@ -2,7 +2,6 @@ import "std:testing" import "std:json" import "std:cache" -auto roturMessageSignals = cache.new({capacity: 100, ttl: 60000}) def normalizeUsername(string name) string ( return name.toLower().strip() diff --git a/rotur-webhooks.osl b/src/rotur/rotur-webhooks.osl similarity index 99% rename from rotur-webhooks.osl rename to src/rotur/rotur-webhooks.osl index 5402976..4f6f9b6 100644 --- a/rotur-webhooks.osl +++ b/src/rotur/rotur-webhooks.osl @@ -1,3 +1,7 @@ +export { + processRoturWebhooks +} + // Webhooks from MistWarp's Rotur App: https://docs.rotur.dev/build-an-app/webhooks // // Rotur tells MistWarp when someone deletes their Rotur account (user.deleted), diff --git a/rotur-webhooks.test.osl b/src/rotur/rotur-webhooks.test.osl similarity index 100% rename from rotur-webhooks.test.osl rename to src/rotur/rotur-webhooks.test.osl diff --git a/src/rotur/routes.osl b/src/rotur/routes.osl new file mode 100644 index 0000000..9250685 --- /dev/null +++ b/src/rotur/routes.osl @@ -0,0 +1,11 @@ +export { + registerRoturRoutes +} + +def registerRoturRoutes(*serve.Router api) ( + api.GET("/perks", handleGetPerks) + api.POST("/perks/refresh", requireAuth, handleRefreshPerks) + api.POST("/perks/seen", requireAuth, handlePerksSeen) + + api.POST("/rotur/webhook", handleRoturWebhook) +) diff --git a/upgrade-policy.osl b/src/rotur/upgrade-policy.osl similarity index 97% rename from upgrade-policy.osl rename to src/rotur/upgrade-policy.osl index dc51db5..36361da 100644 --- a/upgrade-policy.osl +++ b/src/rotur/upgrade-policy.osl @@ -1,3 +1,5 @@ +export {} + def tierRank(string tier) number ( string key = tier.toLower().strip() if key == "lite" ( diff --git a/upgrade-policy.test.osl b/src/rotur/upgrade-policy.test.osl similarity index 100% rename from upgrade-policy.test.osl rename to src/rotur/upgrade-policy.test.osl diff --git a/vanity-policy.osl b/src/rotur/vanity-policy.osl similarity index 97% rename from vanity-policy.osl rename to src/rotur/vanity-policy.osl index 70810d2..2304e37 100644 --- a/vanity-policy.osl +++ b/src/rotur/vanity-policy.osl @@ -1,3 +1,7 @@ +export { + vanityAfter +} + // A vanity project URL needs a plan that includes one. Once the owner's plan // stops including it, the URL stops working but stays reserved for them for // 30 days, so resubscribing brings it straight back. After that it is diff --git a/vanity-policy.test.osl b/src/rotur/vanity-policy.test.osl similarity index 100% rename from vanity-policy.test.osl rename to src/rotur/vanity-policy.test.osl diff --git a/archive-security.osl b/src/storage/archive-security.osl similarity index 88% rename from archive-security.osl rename to src/storage/archive-security.osl index d1216e3..8b4ec17 100644 --- a/archive-security.osl +++ b/src/storage/archive-security.osl @@ -1,3 +1,7 @@ +export { + validateZipArchive +} + def validateZipArchive(byte[] body, number maxCompressedBytes, number maxExpandedBytes, number maxFiles, boolean readContents) boolean ( return storagefs.ValidateZipArchive(body, maxCompressedBytes, maxExpandedBytes, maxFiles, readContents) ) diff --git a/archive-security.test.osl b/src/storage/archive-security.test.osl similarity index 100% rename from archive-security.test.osl rename to src/storage/archive-security.test.osl diff --git a/asset-routing.osl b/src/storage/asset-routing.osl similarity index 94% rename from asset-routing.osl rename to src/storage/asset-routing.osl index 2b55b29..af1ad27 100644 --- a/asset-routing.osl +++ b/src/storage/asset-routing.osl @@ -1,3 +1,7 @@ +export { + assetNeedsUpload +} + def assetUrlForLocation(string name, boolean localExists, string localAssetsBase, string r2PublicBase) string ( if localExists or r2PublicBase == "" ( return localAssetsBase ++ "/" ++ name diff --git a/asset-routing.test.osl b/src/storage/asset-routing.test.osl similarity index 100% rename from asset-routing.test.osl rename to src/storage/asset-routing.test.osl diff --git a/backpack-policy.osl b/src/storage/backpack-policy.osl similarity index 99% rename from backpack-policy.osl rename to src/storage/backpack-policy.osl index 922a087..0101ce1 100644 --- a/backpack-policy.osl +++ b/src/storage/backpack-policy.osl @@ -1,3 +1,5 @@ +export {} + number maxBackpackBodyBytes = 20971520 number maxBackpackThumbnailBytes = 1048576 number maxBackpackItems = 2000 diff --git a/backpack-policy.test.osl b/src/storage/backpack-policy.test.osl similarity index 100% rename from backpack-policy.test.osl rename to src/storage/backpack-policy.test.osl diff --git a/backpack.osl b/src/storage/backpack.osl similarity index 99% rename from backpack.osl rename to src/storage/backpack.osl index 8fd8d62..bbd17a1 100644 --- a/backpack.osl +++ b/src/storage/backpack.osl @@ -1,3 +1,9 @@ +export { + backpackStoredBytes, + collectBackpackForUser, + purgeBackpackForUser +} + def backpackUserKey(string userId) string ( return ("backpack:" ++ userId).hashSHA256() ) diff --git a/quota-policy.osl b/src/storage/quota-policy.osl similarity index 93% rename from quota-policy.osl rename to src/storage/quota-policy.osl index f930b5d..5a1e6b6 100644 --- a/quota-policy.osl +++ b/src/storage/quota-policy.osl @@ -1,3 +1,10 @@ +export { + quotaPruneEvents, + quotaTotalBytes, + quotaWithoutNames, + storageAllowance +} + def quotaPruneEvents(array events, number nowMs, number windowMs) array ( array kept = [] for i events.len ( diff --git a/quota-policy.test.osl b/src/storage/quota-policy.test.osl similarity index 100% rename from quota-policy.test.osl rename to src/storage/quota-policy.test.osl diff --git a/r2-snapshot-serving.osl b/src/storage/r2-snapshot-serving.osl similarity index 97% rename from r2-snapshot-serving.osl rename to src/storage/r2-snapshot-serving.osl index 37a8a20..1e3e943 100644 --- a/r2-snapshot-serving.osl +++ b/src/storage/r2-snapshot-serving.osl @@ -1,3 +1,7 @@ +export { + serveProjectJsonStorageKey +} + def serveProjectJsonStorageKey(*serve.Context c, string key, boolean gzip) ( if !isSafeBlobKey(key) ( c.notFound("project snapshot not found") diff --git a/r2-snapshot-serving.test.osl b/src/storage/r2-snapshot-serving.test.osl similarity index 100% rename from r2-snapshot-serving.test.osl rename to src/storage/r2-snapshot-serving.test.osl diff --git a/r2-upload-queue.osl b/src/storage/r2-upload-queue.osl similarity index 98% rename from r2-upload-queue.osl rename to src/storage/r2-upload-queue.osl index f96fcd5..d93eaba 100644 --- a/r2-upload-queue.osl +++ b/src/storage/r2-upload-queue.osl @@ -1,3 +1,10 @@ +export { + flushR2UploadQueue, + loadR2UploadQueue, + projectStorageActivityAt, + stageR2Blob +} + def projectStorageActivityAt(object project) number ( number edited = (project.edited ?? 0).toNum() number created = (project.created ?? 0).toNum() diff --git a/r2-upload-queue.test.osl b/src/storage/r2-upload-queue.test.osl similarity index 100% rename from r2-upload-queue.test.osl rename to src/storage/r2-upload-queue.test.osl diff --git a/r2.osl b/src/storage/r2.osl similarity index 97% rename from r2.osl rename to src/storage/r2.osl index 64e9ed4..7e78a95 100644 --- a/r2.osl +++ b/src/storage/r2.osl @@ -1,3 +1,32 @@ +export { + assetObjectKey, + assetsBaseUrl, + contentTypeForExt, + copyProjectWorkspaceToPath, + copyStorageKeyToPath, + copyStoredProjectJsonToPath, + keyedThumbUrlFor, + localBlobPath, + projectJsonBaseUrl, + projectJsonIsGzip, + projectJsonKey, + projectJsonUrlFor, + projectViewKey, + r2PutEncoded, + r2Remove, + r2StorageIndexFile, + serveBlobKey, + serveNotModified, + serveProjectJson, + stagingJsonPath, + syncR2StorageInventory, + thumbUrlFor, + thumbnailFileFor, + thumbnailPath, + viewerThumbEntries, + viewerThumbUrlFor +} + def projectJsonKey(string id) string ( return "projects/" ++ id ++ "/project.json" ) diff --git a/src/storage/routes.osl b/src/storage/routes.osl new file mode 100644 index 0000000..353fcbc --- /dev/null +++ b/src/storage/routes.osl @@ -0,0 +1,30 @@ +export { + registerBlobRoutes, + registerStorageRoutes, + registerStorageUploadRoutes +} + +def registerBlobRoutes(*serve.Router app) ( + app.GET("/thumbnails/:file", handleGetThumbnail) + app.GET("/blobs/assets/:name", handleGetAssetBlob) + app.GET("/blobs/backpack/:folder/:file", handleGetBackpackBlob) + app.GET("/blobs/projects/:id/project.json", handleGetProjectBlob) + app.GET("/blobs/evidence/:id/project.json", handleGetEvidenceJson) + app.GET("/blobs/evidence/:id/assets/:name", handleGetEvidenceAsset) + if r2Local ( + app.static("/blobs", "./data/blobs") + ) +) + +def registerStorageUploadRoutes(*serve.Router uploads) ( + uploads.POST("/me/backpack", serve.maxBodySize(maxBackpackRequestBytes), requireAuth, requireCurrentAgreement, handleCreateBackpackItem) +) + +def registerStorageRoutes(*serve.Router api) ( + api.GET("/me/backpack", requireAuth, handleGetMyBackpack) + api.PATCH("/me/backpack/:id", requireAuth, handleUpdateBackpackItem) + api.DELETE("/me/backpack/:id", requireAuth, handleDeleteBackpackItem) + api.POST("/me/backpack/delete", requireAuth, handleDeleteBackpackItems) + + api.GET("/projects/:id/workspace.mwp", handleGetProjectWorkspace) +) diff --git a/storage-addressing.osl b/src/storage/storage-addressing.osl similarity index 87% rename from storage-addressing.osl rename to src/storage/storage-addressing.osl index 819ae59..dd7c805 100644 --- a/storage-addressing.osl +++ b/src/storage/storage-addressing.osl @@ -1,3 +1,10 @@ +export { + contentHash, + projectJsonSnapshotKey, + projectWorkspaceKeys, + projectWorkspaceSnapshotKey +} + def projectJsonSnapshotKey(string hash) string ( return "project-json/" ++ hash ++ ".json.gz" ) diff --git a/storage-addressing.test.osl b/src/storage/storage-addressing.test.osl similarity index 100% rename from storage-addressing.test.osl rename to src/storage/storage-addressing.test.osl diff --git a/storage-ledger-policy.osl b/src/storage/storage-ledger-policy.osl similarity index 99% rename from storage-ledger-policy.osl rename to src/storage/storage-ledger-policy.osl index ace9280..1b4c6bf 100644 --- a/storage-ledger-policy.osl +++ b/src/storage/storage-ledger-policy.osl @@ -1,3 +1,7 @@ +export { + projectStorageSummary +} + // What MistWarp counts against a person's one Rotur storage allowance, which // the Rotur API keeps in its storage ledger. // diff --git a/storage-ledger-policy.test.osl b/src/storage/storage-ledger-policy.test.osl similarity index 100% rename from storage-ledger-policy.test.osl rename to src/storage/storage-ledger-policy.test.osl diff --git a/storage-ledger.osl b/src/storage/storage-ledger.osl similarity index 98% rename from storage-ledger.osl rename to src/storage/storage-ledger.osl index 93268b7..bf92121 100644 --- a/storage-ledger.osl +++ b/src/storage/storage-ledger.osl @@ -1,3 +1,21 @@ +export { + ledgerAllowance, + ledgerFreeAll, + logLedgerSetup, + noStorageHold, + pendingAssetHold, + projectOwnerId, + releasePendingAssetHolds, + storageHold, + storageHoldRelease, + storageReport, + storageSettle, + storageSettleLoop, + storageSettleProject, + storageSettleSoon, + usesRoturStorage +} + // MistWarp's projects, assets and backpack count against each person's one // Rotur storage allowance (storage-ledger-policy.osl says what counts). The // Rotur API keeps the count in its storage ledger: diff --git a/storage-ledger.test.osl b/src/storage/storage-ledger.test.osl similarity index 99% rename from storage-ledger.test.osl rename to src/storage/storage-ledger.test.osl index e758d66..cb7c253 100644 --- a/storage-ledger.test.osl +++ b/src/storage/storage-ledger.test.osl @@ -102,7 +102,7 @@ def pendingUploadEvents(string username) array ( return quotaPruneEvents((res.data.assertElse(object, {}).pending ?? []).assertElse(array, []), timestamp, quotaWindowMs) ) -import "./utils.osl" +import "../core/utils.osl" import "./storage-addressing.osl" import "./quota-policy.osl" import "./storage-ledger-policy.osl" diff --git a/storage-usage.osl b/src/storage/storage-usage.osl similarity index 98% rename from storage-usage.osl rename to src/storage/storage-usage.osl index 7d444d0..531b25c 100644 --- a/storage-usage.osl +++ b/src/storage/storage-usage.osl @@ -1,3 +1,8 @@ +export { + backfillProjectHistoryBytes, + backfillReleaseJsonBytes +} + def storedKeyBytes(string key, object inventory) number ( string path = localBlobPath(key) if fs.exists(path) ( diff --git a/storage.osl b/src/storage/storage.osl similarity index 96% rename from storage.osl rename to src/storage/storage.osl index d98ef96..db1e85e 100644 --- a/storage.osl +++ b/src/storage/storage.osl @@ -1,3 +1,29 @@ +export { + addActivity, + appendComment, + deleteProjectFile, + ensureDirectories, + indexEntryFromProject, + loadActivity, + loadAssetsIndex, + loadComments, + loadProfile, + loadProject, + profileExists, + projectHistoryBytes, + projectSizeBytes, + projectStorageBytes, + readFile, + refreshIndexUrls, + removeFromIndex, + saveAssetsIndex, + saveComments, + saveProfile, + saveProject, + upsertIndexEntry, + writeFile +} + def ensureDirectories() boolean ( boolean res = fs.mkdir(projectsDir, {recursive: true}) res = res and fs.mkdir(usersDir, {recursive: true}) diff --git a/thumbnail-access.osl b/src/storage/thumbnail-access.osl similarity index 94% rename from thumbnail-access.osl rename to src/storage/thumbnail-access.osl index 73e5536..39ce02d 100644 --- a/thumbnail-access.osl +++ b/src/storage/thumbnail-access.osl @@ -1,3 +1,8 @@ +export { + loadThumbnailSecret, + thumbnailIsPublic +} + def loadThumbnailSecret(string path) string ( string stored = fs.readFile(path).unwrapOr("").strip() if stored != "" ( diff --git a/thumbnail-access.test.osl b/src/storage/thumbnail-access.test.osl similarity index 100% rename from thumbnail-access.test.osl rename to src/storage/thumbnail-access.test.osl